@teincfood/core 0.9.23 → 0.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/migrations.js +25 -0
- package/dist/db/operations.d.ts +38 -0
- package/dist/db/operations.js +145 -12
- package/dist/domain/orders/order-flow.d.ts +41 -0
- package/dist/domain/orders/order-flow.js +66 -0
- package/dist/domain/orders/service.js +32 -15
- package/dist/hooks/realtime-stubs.d.ts +26 -19
- package/dist/hooks/realtime-stubs.js +29 -124
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/realtime/events.service.d.ts +9 -0
- package/dist/realtime/events.service.js +37 -7
- package/dist/realtime/realtime-bridge.d.ts +30 -0
- package/dist/realtime/realtime-bridge.js +149 -0
- package/dist/reference/events.d.ts +1 -1
- package/dist/reference/service.d.ts +10 -1
- package/dist/reference/service.js +79 -19
- package/dist/sync/command-queue.service.d.ts +6 -5
- package/dist/sync/command-queue.service.js +10 -14
- package/dist/sync/full-sync.js +9 -5
- package/dist/sync/order-merge.d.ts +25 -0
- package/dist/sync/order-merge.js +66 -0
- package/dist/sync/order-snapshots.service.js +17 -1
- package/dist/sync/pending-orders.service.d.ts +2 -2
- package/dist/sync/sync-jobs.service.d.ts +22 -1
- package/dist/sync/sync-jobs.service.js +81 -22
- package/dist/sync/types.d.ts +7 -0
- package/dist/sync/use-sync-reconciliation.js +12 -3
- package/dist/test-helpers/memory-driver.js +42 -14
- package/dist/types/api/business.api.d.ts +7 -0
- package/package.json +1 -1
|
@@ -50,11 +50,12 @@ declare class CommandQueueService {
|
|
|
50
50
|
*/
|
|
51
51
|
private failDependentTransitions;
|
|
52
52
|
/**
|
|
53
|
-
* Already-applied absorb:
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* Already-applied absorb: the server already moved the order to this
|
|
54
|
+
* command's target status, so the queued command is redundant and can be
|
|
55
|
+
* dropped.
|
|
56
|
+
*
|
|
57
|
+
* The comparison uses the SERVER status reported by the pull — the local
|
|
58
|
+
* row may show this device's own queued intent and would absorb itself.
|
|
58
59
|
*/
|
|
59
60
|
private absorbIfApplied;
|
|
60
61
|
/**
|
|
@@ -586,27 +586,23 @@ class CommandQueueService {
|
|
|
586
586
|
catch { }
|
|
587
587
|
}
|
|
588
588
|
/**
|
|
589
|
-
* Already-applied absorb:
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
589
|
+
* Already-applied absorb: the server already moved the order to this
|
|
590
|
+
* command's target status, so the queued command is redundant and can be
|
|
591
|
+
* dropped.
|
|
592
|
+
*
|
|
593
|
+
* The comparison uses the SERVER status reported by the pull — the local
|
|
594
|
+
* row may show this device's own queued intent and would absorb itself.
|
|
594
595
|
*/
|
|
595
596
|
async absorbIfApplied(command) {
|
|
596
597
|
const target = QUEUED_STATUS_BY_COMMAND[command.command_type];
|
|
597
598
|
if (!target || !command.entity_id)
|
|
598
599
|
return false;
|
|
599
600
|
try {
|
|
600
|
-
const db = getSyncDatabase();
|
|
601
|
-
const before = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, command.business_id, command.entity_id);
|
|
602
601
|
const { pullRemoteOrders } = await import("./sync-jobs.service");
|
|
603
|
-
await pullRemoteOrders(command.business_id);
|
|
604
|
-
const
|
|
605
|
-
if (
|
|
606
|
-
|
|
607
|
-
const { getOrderById } = await import("../db/operations");
|
|
608
|
-
const order = await getOrderById(db, command.business_id, command.entity_id);
|
|
609
|
-
if (order && order.status === target) {
|
|
602
|
+
const serverStatuses = await pullRemoteOrders(command.business_id);
|
|
603
|
+
const serverStatus = serverStatuses.get(command.entity_id);
|
|
604
|
+
if (serverStatus === target) {
|
|
605
|
+
const db = getSyncDatabase();
|
|
610
606
|
await deleteCommand(db, command.command_id);
|
|
611
607
|
retryNotBefore.delete(command.command_id);
|
|
612
608
|
syncLogger.info("CommandQueueService", "Transition already applied on server, absorbing", {
|
package/dist/sync/full-sync.js
CHANGED
|
@@ -17,7 +17,7 @@ import { syncLogger } from "../utils/sync-logger";
|
|
|
17
17
|
import { referenceSyncService } from "../reference/service";
|
|
18
18
|
import { pullRemoteOrders, drainPending } from "./sync-jobs.service";
|
|
19
19
|
import { canBusinessSync } from "./sync-guard";
|
|
20
|
-
|
|
20
|
+
const inFlight = new Map();
|
|
21
21
|
export async function performFullSync(businessId) {
|
|
22
22
|
const bid = businessId ?? getActiveBusiness()?.id;
|
|
23
23
|
if (!bid || !canBusinessSync(bid))
|
|
@@ -25,9 +25,13 @@ export async function performFullSync(businessId) {
|
|
|
25
25
|
const s = getConnectivityState();
|
|
26
26
|
if (!s.isOnline || s.isCloudReachable === "unreachable")
|
|
27
27
|
return;
|
|
28
|
-
|
|
28
|
+
// Single-flight PER BUSINESS. A global in-flight promise meant that
|
|
29
|
+
// switching business while business A was syncing made business B join
|
|
30
|
+
// A's promise and return without syncing at all.
|
|
31
|
+
const running = inFlight.get(bid);
|
|
32
|
+
if (running) {
|
|
29
33
|
try {
|
|
30
|
-
await
|
|
34
|
+
await running;
|
|
31
35
|
}
|
|
32
36
|
catch { }
|
|
33
37
|
return;
|
|
@@ -70,11 +74,11 @@ export async function performFullSync(businessId) {
|
|
|
70
74
|
syncLogger.warn("FullSync", "Failed", { err: String(e) });
|
|
71
75
|
}
|
|
72
76
|
})();
|
|
73
|
-
inFlight
|
|
77
|
+
inFlight.set(bid, p);
|
|
74
78
|
try {
|
|
75
79
|
await p;
|
|
76
80
|
}
|
|
77
81
|
finally {
|
|
78
|
-
inFlight
|
|
82
|
+
inFlight.delete(bid);
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server → local order merge.
|
|
3
|
+
*
|
|
4
|
+
* The server is authoritative for an order — EXCEPT while this device still
|
|
5
|
+
* has unsynced work for it. A pull can land before the queued command
|
|
6
|
+
* reaches the backend (reconnect storms, slow networks, a second terminal
|
|
7
|
+
* pushing at the same time); without the overlay the local terminal would
|
|
8
|
+
* visibly revert an accepted order back to `pending`, or drop a confirmed
|
|
9
|
+
* cash payment, until the queue drained.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* 1. The server row always overwrites the local row (status, totals,
|
|
13
|
+
* delivery linkage, items).
|
|
14
|
+
* 2. If this device has a pending command for the order, the local intent
|
|
15
|
+
* (target status / payment) is re-applied on top of the server row.
|
|
16
|
+
* 3. The local HLC comes from the server's `updated_at`, so device clock
|
|
17
|
+
* skew can never make fresh server state look stale.
|
|
18
|
+
*/
|
|
19
|
+
import type { SQLiteDatabase } from "../adapters/sqlite";
|
|
20
|
+
import type { SellerOrder } from "../types/api/seller.api";
|
|
21
|
+
/**
|
|
22
|
+
* Merge one server order into the local read model.
|
|
23
|
+
* Returns true when the row was written.
|
|
24
|
+
*/
|
|
25
|
+
export declare function mergeOrderFromServer(db: SQLiteDatabase, businessId: string, serverOrder: SellerOrder): Promise<boolean>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server → local order merge.
|
|
3
|
+
*
|
|
4
|
+
* The server is authoritative for an order — EXCEPT while this device still
|
|
5
|
+
* has unsynced work for it. A pull can land before the queued command
|
|
6
|
+
* reaches the backend (reconnect storms, slow networks, a second terminal
|
|
7
|
+
* pushing at the same time); without the overlay the local terminal would
|
|
8
|
+
* visibly revert an accepted order back to `pending`, or drop a confirmed
|
|
9
|
+
* cash payment, until the queue drained.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* 1. The server row always overwrites the local row (status, totals,
|
|
13
|
+
* delivery linkage, items).
|
|
14
|
+
* 2. If this device has a pending command for the order, the local intent
|
|
15
|
+
* (target status / payment) is re-applied on top of the server row.
|
|
16
|
+
* 3. The local HLC comes from the server's `updated_at`, so device clock
|
|
17
|
+
* skew can never make fresh server state look stale.
|
|
18
|
+
*/
|
|
19
|
+
import { getOrderById, getPendingOrderIntent, updateOrderPayment, updateOrderStatus, upsertServerOrder, } from "../db/operations";
|
|
20
|
+
import { syncLogger } from "../utils/sync-logger";
|
|
21
|
+
/**
|
|
22
|
+
* Merge one server order into the local read model.
|
|
23
|
+
* Returns true when the row was written.
|
|
24
|
+
*/
|
|
25
|
+
export async function mergeOrderFromServer(db, businessId, serverOrder) {
|
|
26
|
+
if (!businessId || !serverOrder?.id)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
const intent = await getPendingOrderIntent(db, businessId, serverOrder.id);
|
|
30
|
+
// Capture the local payment BEFORE the server copy overwrites the row —
|
|
31
|
+
// a queued cash sale carries the paid timestamp only on the device.
|
|
32
|
+
const beforeMerge = intent.hasPending
|
|
33
|
+
? await getOrderById(db, businessId, serverOrder.id)
|
|
34
|
+
: null;
|
|
35
|
+
await upsertServerOrder(db, { ...serverOrder, business_id: businessId });
|
|
36
|
+
if (intent.hasPending) {
|
|
37
|
+
if (intent.status) {
|
|
38
|
+
await updateOrderStatus(db, businessId, serverOrder.id, intent.status);
|
|
39
|
+
}
|
|
40
|
+
if (intent.isPaid) {
|
|
41
|
+
const paidAt = beforeMerge?.payment?.paid_at ??
|
|
42
|
+
beforeMerge?.paid_at ??
|
|
43
|
+
new Date().toISOString();
|
|
44
|
+
await updateOrderPayment(db, businessId, serverOrder.id, {
|
|
45
|
+
is_paid: true,
|
|
46
|
+
paid_at: paidAt,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
syncLogger.debug("OrderMerge", "Applied local intent over server row", {
|
|
50
|
+
businessId,
|
|
51
|
+
orderId: serverOrder.id,
|
|
52
|
+
status: intent.status,
|
|
53
|
+
isPaid: intent.isPaid,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
syncLogger.warn("OrderMerge", "Failed to merge server order", {
|
|
60
|
+
businessId,
|
|
61
|
+
orderId: serverOrder?.id,
|
|
62
|
+
error: String(error),
|
|
63
|
+
});
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* exact list last seen while online is available offline.
|
|
15
15
|
*/
|
|
16
16
|
import { openSyncDatabase } from "../db/connection";
|
|
17
|
-
import { insertEvent, upsertEntitySnapshot, getEntitySnapshot, getEntitySnapshotsByType, pruneEntitySnapshots, } from "../db/operations";
|
|
17
|
+
import { insertEvent, upsertEntitySnapshot, getEntitySnapshot, getEntitySnapshotsByType, pruneEntitySnapshots, updateOrderPayment, } from "../db/operations";
|
|
18
18
|
import { syncLogger } from "../utils/sync-logger";
|
|
19
19
|
const RETAIN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
20
20
|
const ORDER_EVENT_TYPES = new Set([
|
|
@@ -119,6 +119,22 @@ class OrderSnapshotsService {
|
|
|
119
119
|
const db = await openSyncDatabase();
|
|
120
120
|
await insertEvent(db, { event, origin });
|
|
121
121
|
const businessId = payload?.business_id ?? payload?.businessId ?? null;
|
|
122
|
+
// Dual-write the canonical orders row: POS/Orders screens read payment
|
|
123
|
+
// from there, not from the snapshot. Without this a mobile-money sale
|
|
124
|
+
// stayed UNPAID (and kept offering "Collect cash") after its payment
|
|
125
|
+
// completed, because only the snapshot list was patched.
|
|
126
|
+
if (businessId) {
|
|
127
|
+
await updateOrderPayment(db, businessId, orderId, { is_paid: true, paid_at: paidAt });
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
try {
|
|
131
|
+
const row = await db.getFirstAsync(`SELECT business_id FROM orders WHERE id = ? OR order_number = ? LIMIT 1;`, orderId, payload?.order_number ?? orderId);
|
|
132
|
+
if (row?.business_id) {
|
|
133
|
+
await updateOrderPayment(db, row.business_id, orderId, { is_paid: true, paid_at: paidAt });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
}
|
|
122
138
|
const lists = businessId
|
|
123
139
|
? await getEntitySnapshotsByType(db, "seller_orders", businessId)
|
|
124
140
|
: await getEntitySnapshotsByType(db, "seller_orders");
|
|
@@ -59,5 +59,5 @@ export declare function hydratePendingOrders(): Promise<void>;
|
|
|
59
59
|
* pending-orders store (only queued creations are); this only fires the
|
|
60
60
|
* invalidator. Idempotent — duplicate deliveries are harmless.
|
|
61
61
|
*/
|
|
62
|
-
export declare function applyOrderStateChangeEvent(event: DomainEvent, origin?: "local" | "lan"): Promise<void>;
|
|
63
|
-
export declare function applyPendingOrderEvent(event: DomainEvent, origin?: "local" | "lan"): Promise<void>;
|
|
62
|
+
export declare function applyOrderStateChangeEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
|
|
63
|
+
export declare function applyPendingOrderEvent(event: DomainEvent, origin?: "local" | "lan" | "cloud"): Promise<void>;
|
|
@@ -4,7 +4,28 @@
|
|
|
4
4
|
* Core-only, single in_flight per business, coalesced via isOnline gate.
|
|
5
5
|
*/
|
|
6
6
|
export declare function drainOne(businessId: string, entityId: string): Promise<void>;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Pull the server's recent orders into the local read model.
|
|
9
|
+
*
|
|
10
|
+
* Returns the SERVER status per order id. The local row can show a
|
|
11
|
+
* different status (a queued transition keeps the device's intent on top of
|
|
12
|
+
* the server copy), so a caller that needs to know what the server actually
|
|
13
|
+
* holds must read it from this map — never from the merged local row.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pullRemoteOrders(businessId: string): Promise<Map<string, string>>;
|
|
16
|
+
/**
|
|
17
|
+
* Fetch exactly ONE order from the server and merge it into the local
|
|
18
|
+
* read model.
|
|
19
|
+
*
|
|
20
|
+
* Used when a realtime `order.created` event arrives for an order this
|
|
21
|
+
* device has never seen: the event carries only a summary, so the order has
|
|
22
|
+
* to be fetched — but fetching the whole list (100 orders, several pages,
|
|
23
|
+
* heavy serialization) for one new order is pure waste on the backend.
|
|
24
|
+
*
|
|
25
|
+
* Uses the existing business-scoped list endpoint with an `order_number`
|
|
26
|
+
* filter, so no backend change is required.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fetchRemoteOrderByNumber(businessId: string, orderNumber: string): Promise<boolean>;
|
|
8
29
|
export declare function drainPending(businessId: string): Promise<void>;
|
|
9
30
|
export declare function setupSyncJobsDrain(): void;
|
|
10
31
|
export declare function getSyncJobsPendingCount(businessId: string): Promise<number>;
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Core-only, single in_flight per business, coalesced via isOnline gate.
|
|
5
5
|
*/
|
|
6
6
|
import { openSyncDatabase } from "../db/connection";
|
|
7
|
-
import {
|
|
7
|
+
import { markOrderSyncFailed } from "../db/operations";
|
|
8
|
+
import { mergeOrderFromServer } from "./order-merge";
|
|
8
9
|
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
9
10
|
import { getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
|
|
10
11
|
import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
|
|
@@ -267,22 +268,19 @@ async function tryRest(businessId, payload, commandType) {
|
|
|
267
268
|
}
|
|
268
269
|
}
|
|
269
270
|
/**
|
|
270
|
-
* Already-applied absorb for sync_jobs rows:
|
|
271
|
-
* order
|
|
272
|
-
*
|
|
273
|
-
*
|
|
271
|
+
* Already-applied absorb for sync_jobs rows: the server already moved the
|
|
272
|
+
* order to this command's target status, so the queued job is redundant.
|
|
273
|
+
*
|
|
274
|
+
* The comparison uses the SERVER status reported by the pull — never the
|
|
275
|
+
* local row, which may show the device's own queued intent.
|
|
274
276
|
*/
|
|
275
277
|
async function absorbSyncJobIfApplied(db, businessId, entityId, commandType, jobId) {
|
|
276
278
|
const target = getQueuedStatus(commandType);
|
|
277
279
|
if (!target || !entityId)
|
|
278
280
|
return false;
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (!afterHlc || afterHlc.hlc === before?.hlc)
|
|
283
|
-
return false;
|
|
284
|
-
const order = await getOrderById(db, businessId, entityId);
|
|
285
|
-
if (order && order.status === target) {
|
|
281
|
+
const serverStatuses = await pullRemoteOrders(businessId);
|
|
282
|
+
const serverStatus = serverStatuses.get(entityId);
|
|
283
|
+
if (serverStatus === target) {
|
|
286
284
|
await db.runAsync(`UPDATE sync_jobs SET status='completed', updated_at=? WHERE id=?;`, new Date().toISOString(), jobId);
|
|
287
285
|
syncLogger.info("SyncJobs", "Transition already applied on server, absorbing job", {
|
|
288
286
|
businessId,
|
|
@@ -379,7 +377,10 @@ export async function drainOne(businessId, entityId) {
|
|
|
379
377
|
inflight.set(key, new Promise((r) => { resolveInflight = r; }));
|
|
380
378
|
try {
|
|
381
379
|
const db = await openSyncDatabase();
|
|
382
|
-
|
|
380
|
+
// FIFO with a stable tiebreaker: two commands created in the same
|
|
381
|
+
// millisecond (fast taps) must still drain in insertion order, otherwise
|
|
382
|
+
// a transition can overtake the command it depends on.
|
|
383
|
+
const row = await db.getFirstAsync(`SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 1;`, businessId, entityId);
|
|
383
384
|
syncLogger.debug("SyncJobs", "drainOne fetched row", { businessId, entityId, found: !!row, command_type: row?.command_type });
|
|
384
385
|
if (!row) {
|
|
385
386
|
syncLogger.debug("SyncJobs", "drainOne no pending row", { businessId, entityId });
|
|
@@ -423,7 +424,7 @@ export async function drainOne(businessId, entityId) {
|
|
|
423
424
|
syncLogger.info("SyncJobs", "Drained via PHX", { businessId, entityId });
|
|
424
425
|
clearRetry(businessId);
|
|
425
426
|
try {
|
|
426
|
-
const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
|
|
427
|
+
const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 1;`, businessId, entityId);
|
|
427
428
|
if (next)
|
|
428
429
|
setTimeout(() => void drainOne(businessId, entityId), 50);
|
|
429
430
|
}
|
|
@@ -444,7 +445,7 @@ export async function drainOne(businessId, entityId) {
|
|
|
444
445
|
clearRetry(businessId);
|
|
445
446
|
// Immediately drain next pending for same entity (FIFO chain: create -> prepare -> ready -> pickup should sync in order without waiting for timer)
|
|
446
447
|
try {
|
|
447
|
-
const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
|
|
448
|
+
const next = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id=? AND entity_id=? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 1;`, businessId, entityId);
|
|
448
449
|
if (next) {
|
|
449
450
|
syncLogger.debug("SyncJobs", "chaining next job for same entity", { businessId, entityId });
|
|
450
451
|
// fire next without inflight block — we already cleared inflight for this key, so recurse
|
|
@@ -632,27 +633,36 @@ export async function drainOne(businessId, entityId) {
|
|
|
632
633
|
syncLogger.debug("SyncJobs", "drainOne exit", { businessId, entityId });
|
|
633
634
|
}
|
|
634
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* Pull the server's recent orders into the local read model.
|
|
638
|
+
*
|
|
639
|
+
* Returns the SERVER status per order id. The local row can show a
|
|
640
|
+
* different status (a queued transition keeps the device's intent on top of
|
|
641
|
+
* the server copy), so a caller that needs to know what the server actually
|
|
642
|
+
* holds must read it from this map — never from the merged local row.
|
|
643
|
+
*/
|
|
635
644
|
export async function pullRemoteOrders(businessId) {
|
|
645
|
+
const serverStatuses = new Map();
|
|
636
646
|
if (!businessId || !canBusinessSync(businessId)) {
|
|
637
647
|
syncLogger.debug("SyncJobs", "pullRemoteOrders early-return — no business/auth", { businessId });
|
|
638
|
-
return;
|
|
648
|
+
return serverStatuses;
|
|
639
649
|
}
|
|
640
650
|
const onlineForRest = isOnlineForRest();
|
|
641
651
|
syncLogger.debug("SyncJobs", "pullRemoteOrders entry", { businessId, onlineForRest, hasToken: isAuthenticated() });
|
|
642
652
|
if (!onlineForRest) {
|
|
643
653
|
syncLogger.debug("SyncJobs", "pullRemoteOrders early-return gate", { businessId, onlineForRest, hasToken: isAuthenticated(), connectivity: getConnectivityState() });
|
|
644
|
-
return;
|
|
654
|
+
return serverStatuses;
|
|
645
655
|
}
|
|
646
656
|
// Respect a global 429 cooldown for background pulls.
|
|
647
657
|
if (isRateLimited()) {
|
|
648
658
|
syncLogger.debug("SyncJobs", "pullRemoteOrders skipped — rate limited", { businessId });
|
|
649
|
-
return;
|
|
659
|
+
return serverStatuses;
|
|
650
660
|
}
|
|
651
661
|
try {
|
|
652
662
|
const http = getHttpClient();
|
|
653
663
|
if (!http) {
|
|
654
664
|
syncLogger.debug("SyncJobs", "pullRemoteOrders no http client", { businessId });
|
|
655
|
-
return;
|
|
665
|
+
return serverStatuses;
|
|
656
666
|
}
|
|
657
667
|
// Gradual recent-first pull for single business — page 1 immediate, then background older pages (max 5 pages = 100 recent)
|
|
658
668
|
let page = 1;
|
|
@@ -671,10 +681,14 @@ export async function pullRemoteOrders(businessId) {
|
|
|
671
681
|
const db = await openSyncDatabase();
|
|
672
682
|
for (const o of data) {
|
|
673
683
|
try {
|
|
674
|
-
|
|
684
|
+
// Server truth — but a pending local command keeps its intent
|
|
685
|
+
// (see order-merge). Never let a pull revert queued work.
|
|
686
|
+
await mergeOrderFromServer(db, businessId, o);
|
|
687
|
+
if (o?.id && o?.status)
|
|
688
|
+
serverStatuses.set(o.id, o.status);
|
|
675
689
|
}
|
|
676
690
|
catch (e) {
|
|
677
|
-
syncLogger.debug("SyncJobs", "pullRemoteOrders
|
|
691
|
+
syncLogger.debug("SyncJobs", "pullRemoteOrders merge fail", { err: String(e) });
|
|
678
692
|
}
|
|
679
693
|
}
|
|
680
694
|
// persist page 1 to snapshot for offline fallback
|
|
@@ -706,6 +720,51 @@ export async function pullRemoteOrders(businessId) {
|
|
|
706
720
|
catch (e) {
|
|
707
721
|
syncLogger.debug("SyncJobs", "pullRemoteDeliveries error", { err: String(e) });
|
|
708
722
|
}
|
|
723
|
+
return serverStatuses;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Fetch exactly ONE order from the server and merge it into the local
|
|
727
|
+
* read model.
|
|
728
|
+
*
|
|
729
|
+
* Used when a realtime `order.created` event arrives for an order this
|
|
730
|
+
* device has never seen: the event carries only a summary, so the order has
|
|
731
|
+
* to be fetched — but fetching the whole list (100 orders, several pages,
|
|
732
|
+
* heavy serialization) for one new order is pure waste on the backend.
|
|
733
|
+
*
|
|
734
|
+
* Uses the existing business-scoped list endpoint with an `order_number`
|
|
735
|
+
* filter, so no backend change is required.
|
|
736
|
+
*/
|
|
737
|
+
export async function fetchRemoteOrderByNumber(businessId, orderNumber) {
|
|
738
|
+
if (!businessId || !orderNumber)
|
|
739
|
+
return false;
|
|
740
|
+
if (!canBusinessSync(businessId))
|
|
741
|
+
return false;
|
|
742
|
+
if (!isOnlineForRest())
|
|
743
|
+
return false;
|
|
744
|
+
if (isRateLimited())
|
|
745
|
+
return false;
|
|
746
|
+
try {
|
|
747
|
+
const http = getHttpClient();
|
|
748
|
+
if (!http)
|
|
749
|
+
return false;
|
|
750
|
+
await paceSync();
|
|
751
|
+
const res = await http.get(`/businesses/${businessId}/orders`, { params: { order_number: orderNumber, page_size: 1 } });
|
|
752
|
+
const payload = res.data;
|
|
753
|
+
const rows = payload?.data ?? [];
|
|
754
|
+
const first = rows[0];
|
|
755
|
+
if (!first?.id) {
|
|
756
|
+
syncLogger.debug("SyncJobs", "fetchRemoteOrderByNumber — no row returned", { businessId, orderNumber });
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
const db = await openSyncDatabase();
|
|
760
|
+
await mergeOrderFromServer(db, businessId, first);
|
|
761
|
+
syncLogger.info("SyncJobs", "Fetched single remote order", { businessId, orderNumber, id: first.id });
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
catch (e) {
|
|
765
|
+
syncLogger.debug("SyncJobs", "fetchRemoteOrderByNumber failed", { businessId, orderNumber, err: String(e) });
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
709
768
|
}
|
|
710
769
|
export async function drainPending(businessId) {
|
|
711
770
|
if (!businessId || !canBusinessSync(businessId)) {
|
|
@@ -720,7 +779,7 @@ export async function drainPending(businessId) {
|
|
|
720
779
|
return;
|
|
721
780
|
}
|
|
722
781
|
const db = await openSyncDatabase();
|
|
723
|
-
const rows = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' ORDER BY created_at LIMIT 20;`, businessId);
|
|
782
|
+
const rows = await db.getAllAsync(`SELECT entity_id FROM sync_jobs WHERE business_id=? AND status='pending' ORDER BY created_at ASC, rowid ASC LIMIT 20;`, businessId);
|
|
724
783
|
syncLogger.debug("SyncJobs", "drainPending found rows", { businessId, count: rows.length, rows });
|
|
725
784
|
for (const r of rows) {
|
|
726
785
|
await drainOne(businessId, r.entity_id);
|
package/dist/sync/types.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export interface EventMetadata {
|
|
|
27
27
|
}
|
|
28
28
|
export interface DomainEvent<T = unknown> extends EventMetadata {
|
|
29
29
|
payload: T;
|
|
30
|
+
/**
|
|
31
|
+
* Where the event came from. Optional for backwards compatibility, but the
|
|
32
|
+
* journal stores it: "local" (this device), "cloud" (Phoenix push) or
|
|
33
|
+
* "lan" (mesh peer). A cloud event must not be re-hydrated as a pending
|
|
34
|
+
* local command after a restart.
|
|
35
|
+
*/
|
|
36
|
+
origin?: "local" | "lan" | "cloud";
|
|
30
37
|
}
|
|
31
38
|
export interface CommandMetadata {
|
|
32
39
|
/** Globally unique command identifier (UUID v4). Used for idempotency. */
|
|
@@ -11,6 +11,7 @@ import { useEffect } from "react";
|
|
|
11
11
|
import { useQueryClient } from "@tanstack/react-query";
|
|
12
12
|
import { syncEngine } from "./engine";
|
|
13
13
|
import { getActiveBusiness } from "../adapters/business";
|
|
14
|
+
import { useGlobalRealtimeBridge } from "../realtime/realtime-bridge";
|
|
14
15
|
import { applyPendingOrderEvent, applyOrderStateChangeEvent, registerPendingOrdersInvalidator, } from "./pending-orders.service";
|
|
15
16
|
import { localNodeService } from "../adapters/local-node";
|
|
16
17
|
import { getSyncDatabase } from "../db/connection";
|
|
@@ -29,6 +30,10 @@ function isLocalOrderEvent(event) {
|
|
|
29
30
|
const RECONCILE_DEBOUNCE_MS = 400;
|
|
30
31
|
export function useSyncReconciliation() {
|
|
31
32
|
const queryClient = useQueryClient();
|
|
33
|
+
// Single app-wide Phoenix subscription. Mounting it here means one root
|
|
34
|
+
// `useSyncReconciliation` is enough for the whole app — screens no longer
|
|
35
|
+
// need their own realtime subscription.
|
|
36
|
+
useGlobalRealtimeBridge();
|
|
32
37
|
useEffect(() => {
|
|
33
38
|
let timer;
|
|
34
39
|
const pending = new Set();
|
|
@@ -102,12 +107,16 @@ export function useSyncReconciliation() {
|
|
|
102
107
|
!localNodeService.isLocalNode()) {
|
|
103
108
|
localNodeService.getClient()?.publishEvent?.(event);
|
|
104
109
|
}
|
|
110
|
+
// Where the event came from: a Phoenix push is "cloud", a LAN relay is
|
|
111
|
+
// "lan", everything produced on this device is "local". The journal
|
|
112
|
+
// stores it so a cloud event is never re-hydrated as pending local work.
|
|
113
|
+
const origin = event.origin ?? "local";
|
|
105
114
|
// Reference data changed (locally or echoed from the cloud): re-sync the
|
|
106
115
|
// affected keys and refresh caches on this device.
|
|
107
|
-
await applyReferenceChangeEvent(event,
|
|
116
|
+
await applyReferenceChangeEvent(event, origin);
|
|
108
117
|
// Order/delivery state changed: refresh order caches so this terminal
|
|
109
118
|
// shows the same order states as the device that made the change.
|
|
110
|
-
await applyOrderStateChangeEvent(event,
|
|
119
|
+
await applyOrderStateChangeEvent(event, origin);
|
|
111
120
|
// Any order/delivery event should refresh dashboard recent orders immediately (fresh on transition)
|
|
112
121
|
if (event.event_type.startsWith("order.") || event.event_type.startsWith("delivery.")) {
|
|
113
122
|
schedule("pending");
|
|
@@ -116,7 +125,7 @@ export function useSyncReconciliation() {
|
|
|
116
125
|
// into the pending-orders store.
|
|
117
126
|
if (event.event_type === "order.create_queued" ||
|
|
118
127
|
event.event_type === "order.created") {
|
|
119
|
-
await applyPendingOrderEvent(event,
|
|
128
|
+
await applyPendingOrderEvent(event, origin);
|
|
120
129
|
}
|
|
121
130
|
if (event.event_type === "order.created" && isLocalOrderEvent(event)) {
|
|
122
131
|
try {
|
|
@@ -8,7 +8,7 @@ export function createMemoryDriver() {
|
|
|
8
8
|
orders: [],
|
|
9
9
|
sync_jobs: [],
|
|
10
10
|
};
|
|
11
|
-
let autoId = { commands: 1, events: 1, migrations: 1 };
|
|
11
|
+
let autoId = { commands: 1, events: 1, migrations: 1, sync_jobs: 1 };
|
|
12
12
|
const driver = {
|
|
13
13
|
async execAsync(_sql) {
|
|
14
14
|
// No-op: CREATE TABLE / INDEX / ALTER all just ensure table array exists.
|
|
@@ -189,16 +189,19 @@ export function createMemoryDriver() {
|
|
|
189
189
|
}
|
|
190
190
|
// orders insert (primary DB)
|
|
191
191
|
if (s.includes("INSERT INTO orders")) {
|
|
192
|
-
const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc] = args;
|
|
192
|
+
const [id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc] = args;
|
|
193
193
|
const existing = tables.orders.find((r) => r.id === id);
|
|
194
|
-
// LWW check via hlc (if existing and incoming older, skip)
|
|
194
|
+
// LWW check via hlc (if existing and incoming older, skip) — only for
|
|
195
|
+
// the local/LAN write path. The server write path (`upsertServerOrder`)
|
|
196
|
+
// has no hlc guard: the server copy always wins.
|
|
197
|
+
const lwwGuarded = s.includes("excluded.hlc >= orders.hlc");
|
|
195
198
|
if (existing) {
|
|
196
|
-
if (hlc && existing.hlc && hlc < existing.hlc)
|
|
199
|
+
if (lwwGuarded && hlc && existing.hlc && hlc < existing.hlc)
|
|
197
200
|
return { lastInsertRowId: 0, changes: 0 };
|
|
198
|
-
Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc });
|
|
201
|
+
Object.assign(existing, { business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? existing.paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc });
|
|
199
202
|
return { lastInsertRowId: 0, changes: 1 };
|
|
200
203
|
}
|
|
201
|
-
tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc, deleted_at: null });
|
|
204
|
+
tables.orders.push({ id, business_id, order_number, callout_number, status, order_type, is_pos, items, subtotal_minor, total_minor, tax_minor, tax_lines, payment, paid_at: paid_at ?? null, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, updated_at, hlc, deleted_at: null });
|
|
202
205
|
return { lastInsertRowId: 1, changes: 1 };
|
|
203
206
|
}
|
|
204
207
|
// sync_jobs insert
|
|
@@ -206,7 +209,7 @@ export function createMemoryDriver() {
|
|
|
206
209
|
const [id, business_id, command_type, entity_id, payload, created_at, updated_at] = args;
|
|
207
210
|
if (tables.sync_jobs.find((r) => r.id === id))
|
|
208
211
|
return { lastInsertRowId: 0, changes: 0 };
|
|
209
|
-
tables.sync_jobs.push({ id, business_id, command_type, entity_id, payload, status: "pending", attempts: 0, created_at, updated_at });
|
|
212
|
+
tables.sync_jobs.push({ rowid: autoId.sync_jobs++, id, business_id, command_type, entity_id, payload, status: "pending", attempts: 0, created_at, updated_at });
|
|
210
213
|
return { lastInsertRowId: 1, changes: 1 };
|
|
211
214
|
}
|
|
212
215
|
// sync_jobs update status=completed
|
|
@@ -260,10 +263,11 @@ export function createMemoryDriver() {
|
|
|
260
263
|
return { lastInsertRowId: 0, changes: 1 };
|
|
261
264
|
}
|
|
262
265
|
if (s.includes("UPDATE orders SET payment = ?")) {
|
|
263
|
-
const [payment, hlc, business_id, id1, id2] = args;
|
|
266
|
+
const [payment, paid_at, hlc, business_id, id1, id2] = args;
|
|
264
267
|
const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
|
|
265
268
|
if (row) {
|
|
266
269
|
row.payment = payment;
|
|
270
|
+
row.paid_at = row.paid_at ?? paid_at ?? null;
|
|
267
271
|
row.hlc = hlc;
|
|
268
272
|
}
|
|
269
273
|
return { lastInsertRowId: 0, changes: 1 };
|
|
@@ -353,6 +357,12 @@ export function createMemoryDriver() {
|
|
|
353
357
|
const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
|
|
354
358
|
return row ?? null;
|
|
355
359
|
}
|
|
360
|
+
// orders business lookup by id/number (payment event with no business_id)
|
|
361
|
+
if (s.includes("SELECT business_id FROM orders WHERE id = ? OR order_number = ?")) {
|
|
362
|
+
const [id1, id2] = args;
|
|
363
|
+
const row = tables.orders.find((r) => r.id === id1 || r.order_number === id2);
|
|
364
|
+
return (row ? { business_id: row.business_id } : null);
|
|
365
|
+
}
|
|
356
366
|
// orders hlc probe (absorb-if-applied touch detection)
|
|
357
367
|
if (s.includes("SELECT hlc FROM orders WHERE business_id = ? AND id = ?")) {
|
|
358
368
|
const [business_id, id] = args;
|
|
@@ -365,6 +375,15 @@ export function createMemoryDriver() {
|
|
|
365
375
|
const row = tables.sync_jobs.find((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending");
|
|
366
376
|
return row ?? null;
|
|
367
377
|
}
|
|
378
|
+
// sync_jobs duplicate-command probe (transition coalescing)
|
|
379
|
+
if (s.includes("SELECT id FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND command_type = ?")) {
|
|
380
|
+
const [business_id, entity_id, command_type] = args;
|
|
381
|
+
const row = tables.sync_jobs.find((r) => r.business_id === business_id &&
|
|
382
|
+
r.entity_id === entity_id &&
|
|
383
|
+
r.command_type === command_type &&
|
|
384
|
+
r.status === "pending");
|
|
385
|
+
return row ?? null;
|
|
386
|
+
}
|
|
368
387
|
// generic orders exists check for migrations backfill: SELECT * FROM orders WHERE business_id=? (not needed)
|
|
369
388
|
return null;
|
|
370
389
|
},
|
|
@@ -434,20 +453,20 @@ export function createMemoryDriver() {
|
|
|
434
453
|
if (s.includes("json_extract(payment, '$.is_paid') = 1")) {
|
|
435
454
|
rows = rows.filter((r) => {
|
|
436
455
|
try {
|
|
437
|
-
return JSON.parse(r.payment ?? "{}").is_paid === true;
|
|
456
|
+
return JSON.parse(r.payment ?? "{}").is_paid === true || r.paid_at != null;
|
|
438
457
|
}
|
|
439
458
|
catch {
|
|
440
|
-
return
|
|
459
|
+
return r.paid_at != null;
|
|
441
460
|
}
|
|
442
461
|
});
|
|
443
462
|
}
|
|
444
|
-
else if (s.includes("json_extract(payment, '$.is_paid')
|
|
463
|
+
else if (s.includes("json_extract(payment, '$.is_paid'), 0) = 0")) {
|
|
445
464
|
rows = rows.filter((r) => {
|
|
446
465
|
try {
|
|
447
|
-
return JSON.parse(r.payment ?? "{}").is_paid !== true;
|
|
466
|
+
return JSON.parse(r.payment ?? "{}").is_paid !== true && r.paid_at == null;
|
|
448
467
|
}
|
|
449
468
|
catch {
|
|
450
|
-
return
|
|
469
|
+
return r.paid_at == null;
|
|
451
470
|
}
|
|
452
471
|
});
|
|
453
472
|
}
|
|
@@ -479,10 +498,19 @@ export function createMemoryDriver() {
|
|
|
479
498
|
const [business_id] = args;
|
|
480
499
|
const m = s.match(/LIMIT\s+(\d+)/);
|
|
481
500
|
const lim = m ? parseInt(m[1], 10) : 20;
|
|
482
|
-
let rows = tables.sync_jobs.filter((r) => r.business_id === business_id && r.status === "pending").sort((a, b) => (a.created_at < b.created_at ? -1 : 1)).map((r) => ({ entity_id: r.entity_id }));
|
|
501
|
+
let rows = tables.sync_jobs.filter((r) => r.business_id === business_id && r.status === "pending").sort((a, b) => (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.rowid - b.rowid)).map((r) => ({ entity_id: r.entity_id }));
|
|
483
502
|
rows = rows.slice(0, lim);
|
|
484
503
|
return rows;
|
|
485
504
|
}
|
|
505
|
+
// sync_jobs command list for a single entity (pending intent)
|
|
506
|
+
if (s.includes("FROM sync_jobs WHERE business_id = ? AND entity_id = ?")) {
|
|
507
|
+
const [business_id, entity_id] = args;
|
|
508
|
+
const rows = tables.sync_jobs
|
|
509
|
+
.filter((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending")
|
|
510
|
+
.sort((a, b) => (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.rowid - b.rowid))
|
|
511
|
+
.map((r) => ({ command_type: r.command_type, payload: r.payload }));
|
|
512
|
+
return rows;
|
|
513
|
+
}
|
|
486
514
|
if (s.includes("SELECT * FROM sync_jobs WHERE") || s.includes("SELECT id, command_type, status, created_at FROM sync_jobs")) {
|
|
487
515
|
const [business_id] = args;
|
|
488
516
|
const rows = tables.sync_jobs.filter((r) => r.business_id === business_id);
|