@teincfood/core 0.9.23 → 0.9.24
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/operations.d.ts +31 -0
- package/dist/db/operations.js +55 -0
- package/dist/domain/orders/service.js +25 -13
- package/dist/hooks/delivery.hooks.d.ts +24 -24
- package/dist/hooks/realtime-stubs.d.ts +26 -19
- package/dist/hooks/realtime-stubs.js +29 -124
- package/dist/hooks/rider.hooks.d.ts +48 -48
- package/dist/hooks/use-infinite-list.d.ts +24 -24
- package/dist/hooks/wallet.hooks.d.ts +24 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -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 +61 -0
- 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 +26 -5
- package/package.json +2 -2
package/dist/db/operations.d.ts
CHANGED
|
@@ -74,6 +74,37 @@ export declare function insertOrder(db: SQLiteDatabase, order: import("../types/
|
|
|
74
74
|
hlc?: string;
|
|
75
75
|
}): Promise<void>;
|
|
76
76
|
export declare function getOrderById(db: SQLiteDatabase, businessId: string, idOrNumber: string): Promise<import("../types/api/seller.api").SellerOrder | null>;
|
|
77
|
+
/**
|
|
78
|
+
* Local intent for an order that still has unsynced work in `sync_jobs`.
|
|
79
|
+
*
|
|
80
|
+
* While a command is queued the device's intent must win over the server
|
|
81
|
+
* copy: a pull that lands before the command syncs would otherwise revert a
|
|
82
|
+
* queued accept (or a queued cash payment) back to the server's older state.
|
|
83
|
+
*
|
|
84
|
+
* `status` is the target status of the LAST queued transition, `isPaid` is
|
|
85
|
+
* true when a cash confirmation is still queued.
|
|
86
|
+
*/
|
|
87
|
+
export interface PendingOrderIntent {
|
|
88
|
+
status: string | null;
|
|
89
|
+
isPaid: boolean;
|
|
90
|
+
hasPending: boolean;
|
|
91
|
+
}
|
|
92
|
+
export declare function getPendingOrderIntent(db: SQLiteDatabase, businessId: string, orderId: string): Promise<PendingOrderIntent>;
|
|
93
|
+
/**
|
|
94
|
+
* Write a server order into the local read model.
|
|
95
|
+
*
|
|
96
|
+
* Unlike `insertOrder` (which is HLC-guarded for local/LAN writes), the
|
|
97
|
+
* server copy is authoritative: it always overwrites the row. The only
|
|
98
|
+
* exception is an order with unsynced local work — its status and payment
|
|
99
|
+
* stay as the device intended until the queued command is acknowledged.
|
|
100
|
+
*
|
|
101
|
+
* The HLC is taken from the server's own `updated_at` so device clock skew
|
|
102
|
+
* can never make a fresh server state look stale.
|
|
103
|
+
*/
|
|
104
|
+
export declare function upsertServerOrder(db: SQLiteDatabase, order: import("../types/api/seller.api").SellerOrder, opts?: {
|
|
105
|
+
preserveStatus?: boolean;
|
|
106
|
+
preservePayment?: boolean;
|
|
107
|
+
}): Promise<void>;
|
|
77
108
|
export declare function listOrders(db: SQLiteDatabase, businessId: string, filters?: {
|
|
78
109
|
status?: string;
|
|
79
110
|
q?: string;
|
package/dist/db/operations.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Low-level SQLite operations for the command outbox and event journal.
|
|
3
3
|
*/
|
|
4
4
|
import { syncLogger } from "../utils/sync-logger";
|
|
5
|
+
import { getQueuedStatus } from "../utils/order-transitions";
|
|
6
|
+
/** Target status a queued command will produce on the server. */
|
|
7
|
+
function queuedStatusOf(commandType) {
|
|
8
|
+
return getQueuedStatus(commandType);
|
|
9
|
+
}
|
|
5
10
|
export async function insertCommand(db, input) {
|
|
6
11
|
const { command, status = "pending" } = input;
|
|
7
12
|
const now = new Date().toISOString();
|
|
@@ -245,6 +250,56 @@ export async function getOrderById(db, businessId, idOrNumber) {
|
|
|
245
250
|
const row = await db.getFirstAsync(`SELECT * FROM orders WHERE business_id = ? AND (id = ? OR order_number = ?) LIMIT 1;`, businessId, idOrNumber, idOrNumber);
|
|
246
251
|
return row ? orderRowToSellerOrder(row) : null;
|
|
247
252
|
}
|
|
253
|
+
export async function getPendingOrderIntent(db, businessId, orderId) {
|
|
254
|
+
const empty = { status: null, isPaid: false, hasPending: false };
|
|
255
|
+
try {
|
|
256
|
+
const rows = await db.getAllAsync(`SELECT command_type FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status = 'pending' ORDER BY created_at ASC, rowid ASC;`, businessId, orderId);
|
|
257
|
+
if (!rows.length)
|
|
258
|
+
return empty;
|
|
259
|
+
let status = null;
|
|
260
|
+
let isPaid = false;
|
|
261
|
+
for (const row of rows) {
|
|
262
|
+
const commandType = row.command_type ?? "";
|
|
263
|
+
const target = queuedStatusOf(commandType);
|
|
264
|
+
if (target)
|
|
265
|
+
status = target;
|
|
266
|
+
if (commandType === "payment:confirm_cash" || commandType === "order:confirm_cash") {
|
|
267
|
+
isPaid = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { status, isPaid, hasPending: true };
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return empty;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Write a server order into the local read model.
|
|
278
|
+
*
|
|
279
|
+
* Unlike `insertOrder` (which is HLC-guarded for local/LAN writes), the
|
|
280
|
+
* server copy is authoritative: it always overwrites the row. The only
|
|
281
|
+
* exception is an order with unsynced local work — its status and payment
|
|
282
|
+
* stay as the device intended until the queued command is acknowledged.
|
|
283
|
+
*
|
|
284
|
+
* The HLC is taken from the server's own `updated_at` so device clock skew
|
|
285
|
+
* can never make a fresh server state look stale.
|
|
286
|
+
*/
|
|
287
|
+
export async function upsertServerOrder(db, order, opts = {}) {
|
|
288
|
+
if (!order?.id || !order?.business_id)
|
|
289
|
+
return;
|
|
290
|
+
const existing = await getOrderById(db, order.business_id, order.id);
|
|
291
|
+
const status = opts.preserveStatus && existing ? existing.status : order.status;
|
|
292
|
+
const payment = opts.preservePayment && existing ? existing.payment : order.payment;
|
|
293
|
+
const hlc = order.hlc ??
|
|
294
|
+
order.updated_at ??
|
|
295
|
+
order.inserted_at ??
|
|
296
|
+
new Date().toISOString();
|
|
297
|
+
const updatedAt = order.updated_at ?? order.inserted_at;
|
|
298
|
+
await db.runAsync(`INSERT INTO orders (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, updated_at, hlc)
|
|
299
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
300
|
+
ON CONFLICT(id) DO UPDATE SET business_id=excluded.business_id, order_number=excluded.order_number, callout_number=excluded.callout_number, status=excluded.status, order_type=excluded.order_type, is_pos=excluded.is_pos, items=excluded.items, subtotal_minor=excluded.subtotal_minor, total_minor=excluded.total_minor, tax_minor=excluded.tax_minor, tax_lines=excluded.tax_lines, payment=excluded.payment, customer_name=excluded.customer_name, customer_phone=excluded.customer_phone, pos_payment_method=excluded.pos_payment_method, delivery_id=excluded.delivery_id, business=excluded.business, user=excluded.user, updated_at=excluded.updated_at, hlc=excluded.hlc, sync_failed_at=NULL, sync_error=NULL;`, order.id, order.business_id, order.order_number, order.callout_number, status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items ?? []), order.subtotal_minor ?? 0, order.total_minor ?? 0, order.tax_minor ?? 0, JSON.stringify(order.tax_lines ?? []), JSON.stringify(payment), order.customer_name, order.customer_phone, order.pos_payment_method, order.delivery_id, order.business ? JSON.stringify(order.business) : null, order.user ? JSON.stringify(order.user) : null, order.inserted_at, updatedAt, hlc);
|
|
301
|
+
notifyDbChange(order.business_id, "orders");
|
|
302
|
+
}
|
|
248
303
|
export async function listOrders(db, businessId, filters, page = 1, pageSize = 20) {
|
|
249
304
|
const where = ["business_id = ?"];
|
|
250
305
|
const args = [businessId];
|
|
@@ -101,7 +101,13 @@ export async function createLocalOrder(businessId, input) {
|
|
|
101
101
|
if (!dd)
|
|
102
102
|
throw new Error("No device number leased (99 max) — connect online once to lease DD");
|
|
103
103
|
const id = (input.id ?? input.command_id ?? generateUUID());
|
|
104
|
-
|
|
104
|
+
// Order numbers must be real strings. A non-string value used to be
|
|
105
|
+
// coerced with `.toString()`, which wrote "[object Object]" into the local
|
|
106
|
+
// DB and broke every lookup for that order.
|
|
107
|
+
const rawOrderNumber = input.order_number;
|
|
108
|
+
const orderNumber = typeof rawOrderNumber === "string" && /^[A-Za-z0-9-]{1,32}$/.test(rawOrderNumber)
|
|
109
|
+
? rawOrderNumber
|
|
110
|
+
: generateOrderNumberForBusiness(businessId);
|
|
105
111
|
const callout = parseInt(orderNumber.split("-")[2] ?? "0", 10) || 0;
|
|
106
112
|
// Use client-provided timestamps for offline-first sync so records created
|
|
107
113
|
// while offline retain their original creation time.
|
|
@@ -195,10 +201,19 @@ export async function transitionLocalOrder(businessId, orderId, action) {
|
|
|
195
201
|
}
|
|
196
202
|
try {
|
|
197
203
|
const now = new Date().toISOString();
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
const commandType = `order:${action}`;
|
|
205
|
+
// Coalesce duplicates: the same transition queued twice (double tap,
|
|
206
|
+
// replay) must not be sent to the backend twice.
|
|
207
|
+
const duplicate = await db.getFirstAsync(`SELECT id FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND command_type = ? AND status = 'pending' LIMIT 1;`, businessId, orderId, commandType);
|
|
208
|
+
if (!duplicate) {
|
|
209
|
+
const jobId = generateUUID();
|
|
210
|
+
await db.runAsync(`INSERT OR IGNORE INTO sync_jobs (id, business_id, command_type, entity_id, payload, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?);`, jobId, businessId, commandType, orderId, JSON.stringify({ action, orderId }), now, now);
|
|
211
|
+
syncLogger.debug("DomainOrders", "transition sync_jobs enqueued", { businessId, orderId, action, jobId });
|
|
212
|
+
void drainOne(businessId, orderId).catch((e) => syncLogger.debug("DomainOrders", "drainOne after transition caught", { err: String(e) }));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
syncLogger.debug("DomainOrders", "transition already queued — job not duplicated", { businessId, orderId, action, jobId: duplicate.id });
|
|
216
|
+
}
|
|
202
217
|
}
|
|
203
218
|
catch (e) {
|
|
204
219
|
syncLogger.debug("DomainOrders", "transition enqueue fail", { err: String(e) });
|
|
@@ -215,15 +230,12 @@ export async function listLocalOrders(businessId, filters, page = 1, pageSize =
|
|
|
215
230
|
const db = await openSyncDatabase();
|
|
216
231
|
const res = await listOrders(db, businessId, filters, page, pageSize);
|
|
217
232
|
if (res.total === 0) {
|
|
218
|
-
//
|
|
233
|
+
// Cold device (no local rows yet): fetch in the BACKGROUND. Blocking the
|
|
234
|
+
// render on a multi-page network pull froze the screen for seconds on a
|
|
235
|
+
// fresh terminal; the pull writes to SQLite and the reconciliation
|
|
236
|
+
// bridge refreshes the list when it lands.
|
|
219
237
|
if (isOnline() && canBusinessSync(businessId)) {
|
|
220
|
-
|
|
221
|
-
await pullRemoteOrders(businessId);
|
|
222
|
-
}
|
|
223
|
-
catch { }
|
|
224
|
-
const afterPull = await listOrders(db, businessId, filters, page, pageSize);
|
|
225
|
-
if (afterPull.total > 0)
|
|
226
|
-
return afterPull;
|
|
238
|
+
void pullRemoteOrders(businessId).catch((e) => syncLogger.debug("DomainOrders", "background pull failed", { businessId, err: String(e) }));
|
|
227
239
|
}
|
|
228
240
|
// filtered miss — still try backfill re-read
|
|
229
241
|
if (!filters || Object.keys(filters).length === 0) {
|
|
@@ -36,10 +36,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
|
|
|
36
36
|
isRefetching: boolean;
|
|
37
37
|
isStale: boolean;
|
|
38
38
|
isEnabled: boolean;
|
|
39
|
-
refetch: (options?: import("@tanstack/
|
|
40
|
-
fetchStatus: import("@tanstack/
|
|
41
|
-
fetchNextPage: (options?: import("@tanstack/
|
|
42
|
-
fetchPreviousPage: (options?: import("@tanstack/
|
|
39
|
+
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
40
|
+
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
41
|
+
fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
42
|
+
fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
43
43
|
hasNextPage: boolean;
|
|
44
44
|
hasPreviousPage: boolean;
|
|
45
45
|
isFetchingNextPage: boolean;
|
|
@@ -70,10 +70,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
|
|
|
70
70
|
isRefetching: boolean;
|
|
71
71
|
isStale: boolean;
|
|
72
72
|
isEnabled: boolean;
|
|
73
|
-
refetch: (options?: import("@tanstack/
|
|
74
|
-
fetchStatus: import("@tanstack/
|
|
75
|
-
fetchNextPage: (options?: import("@tanstack/
|
|
76
|
-
fetchPreviousPage: (options?: import("@tanstack/
|
|
73
|
+
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
74
|
+
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
75
|
+
fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
76
|
+
fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
77
77
|
hasNextPage: boolean;
|
|
78
78
|
hasPreviousPage: boolean;
|
|
79
79
|
isFetchingNextPage: boolean;
|
|
@@ -105,10 +105,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
|
|
|
105
105
|
isRefetching: boolean;
|
|
106
106
|
isStale: boolean;
|
|
107
107
|
isEnabled: boolean;
|
|
108
|
-
refetch: (options?: import("@tanstack/
|
|
109
|
-
fetchStatus: import("@tanstack/
|
|
110
|
-
fetchNextPage: (options?: import("@tanstack/
|
|
111
|
-
fetchPreviousPage: (options?: import("@tanstack/
|
|
108
|
+
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
109
|
+
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
110
|
+
fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
111
|
+
fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
112
112
|
hasNextPage: boolean;
|
|
113
113
|
hasPreviousPage: boolean;
|
|
114
114
|
isFetchingNextPage: boolean;
|
|
@@ -138,10 +138,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
|
|
|
138
138
|
isRefetching: boolean;
|
|
139
139
|
isStale: boolean;
|
|
140
140
|
isEnabled: boolean;
|
|
141
|
-
refetch: (options?: import("@tanstack/
|
|
142
|
-
fetchStatus: import("@tanstack/
|
|
143
|
-
fetchNextPage: (options?: import("@tanstack/
|
|
144
|
-
fetchPreviousPage: (options?: import("@tanstack/
|
|
141
|
+
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
142
|
+
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
143
|
+
fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
144
|
+
fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
145
145
|
hasNextPage: boolean;
|
|
146
146
|
hasPreviousPage: boolean;
|
|
147
147
|
isFetchingNextPage: boolean;
|
|
@@ -172,10 +172,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
|
|
|
172
172
|
isRefetching: boolean;
|
|
173
173
|
isStale: boolean;
|
|
174
174
|
isEnabled: boolean;
|
|
175
|
-
refetch: (options?: import("@tanstack/
|
|
176
|
-
fetchStatus: import("@tanstack/
|
|
177
|
-
fetchNextPage: (options?: import("@tanstack/
|
|
178
|
-
fetchPreviousPage: (options?: import("@tanstack/
|
|
175
|
+
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
176
|
+
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
177
|
+
fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
178
|
+
fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
179
179
|
hasNextPage: boolean;
|
|
180
180
|
hasPreviousPage: boolean;
|
|
181
181
|
isFetchNextPageError: boolean;
|
|
@@ -206,10 +206,10 @@ export declare function useBusinessDeliveriesQuery(enabled?: boolean, filters?:
|
|
|
206
206
|
isRefetching: boolean;
|
|
207
207
|
isStale: boolean;
|
|
208
208
|
isEnabled: boolean;
|
|
209
|
-
refetch: (options?: import("@tanstack/
|
|
210
|
-
fetchStatus: import("@tanstack/
|
|
211
|
-
fetchNextPage: (options?: import("@tanstack/
|
|
212
|
-
fetchPreviousPage: (options?: import("@tanstack/
|
|
209
|
+
refetch: (options?: import("@tanstack/query-core").RefetchOptions) => Promise<import("@tanstack/query-core").QueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
210
|
+
fetchStatus: import("@tanstack/query-core").FetchStatus;
|
|
211
|
+
fetchNextPage: (options?: import("@tanstack/query-core").FetchNextPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
212
|
+
fetchPreviousPage: (options?: import("@tanstack/query-core").FetchPreviousPageOptions) => Promise<import("@tanstack/query-core").InfiniteQueryObserverResult<import("@tanstack/query-core").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
|
|
213
213
|
hasNextPage: boolean;
|
|
214
214
|
hasPreviousPage: boolean;
|
|
215
215
|
isFetchingNextPage: boolean;
|
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Realtime invalidation hooks
|
|
3
|
-
* Sync Engine so useSyncReconciliation fires and React Query caches refresh.
|
|
2
|
+
* Realtime invalidation hooks.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The cloud → local event path now has a single owner: the global realtime
|
|
5
|
+
* bridge (`src/realtime/realtime-bridge.ts`), mounted once per app. It
|
|
6
|
+
* publishes every cloud event through the Sync Engine, and
|
|
7
|
+
* `useSyncReconciliation` reacts by journaling the event, patching the
|
|
8
|
+
* offline read model, invalidating the affected caches (debounced, 400 ms)
|
|
9
|
+
* and relaying the event to LAN peers.
|
|
10
|
+
*
|
|
11
|
+
* `useOrderRealtimeUpdates` used to be mounted per screen (POS, Orders,
|
|
12
|
+
* Kitchen, Display). That meant every mounted screen processed the same
|
|
13
|
+
* event — duplicated persistence, duplicated invalidations, duplicated
|
|
14
|
+
* fetches — and every screen that did NOT mount it (Dashboard, Settings)
|
|
15
|
+
* never saw another terminal's order at all. It is kept as a no-op so
|
|
16
|
+
* existing call sites keep compiling; new code must not use it.
|
|
17
|
+
*
|
|
18
|
+
* Invalidations stay coalesced: a reconnect/replay burst can deliver dozens
|
|
7
19
|
* of events at once, and an immediate invalidate-per-event forces repeated
|
|
8
|
-
* full re-renders of boards/grids — a major CPU spike
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
20
|
+
* full re-renders of boards/grids — a major CPU spike on low-power POS
|
|
21
|
+
* hardware. One invalidation per ~400 ms window keeps the UI fresh without
|
|
22
|
+
* the render storm.
|
|
11
23
|
*/
|
|
12
24
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Each event is also persisted to the durable offline read model so order
|
|
17
|
-
* history and states survive a restart while offline, and published through
|
|
18
|
-
* the Sync Engine so useSyncReconciliation + LAN relay fire without polling.
|
|
19
|
-
*
|
|
20
|
-
* Call once per screen that needs live updates (POS, Orders, Kitchen, etc.).
|
|
25
|
+
* @deprecated Mount the global bridge once at the app root instead
|
|
26
|
+
* (`useRealtimeInvalidation` or `useSyncReconciliation`). Kept as a no-op
|
|
27
|
+
* so legacy call sites remain source-compatible.
|
|
21
28
|
*/
|
|
22
|
-
export declare function useOrderRealtimeUpdates(
|
|
29
|
+
export declare function useOrderRealtimeUpdates(_rootKey: readonly string[]): void;
|
|
23
30
|
/**
|
|
24
|
-
* Global realtime invalidation —
|
|
25
|
-
*
|
|
26
|
-
*
|
|
31
|
+
* Global realtime invalidation — mounts the single app-wide Phoenix
|
|
32
|
+
* subscription. Pair with `useSyncReconciliation` (which reacts to the
|
|
33
|
+
* published events) at the app root.
|
|
27
34
|
*/
|
|
28
35
|
export declare function useRealtimeInvalidation(): void;
|
|
@@ -1,133 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Realtime invalidation hooks
|
|
3
|
-
* Sync Engine so useSyncReconciliation fires and React Query caches refresh.
|
|
2
|
+
* Realtime invalidation hooks.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*/
|
|
12
|
-
import { useEffect } from "react";
|
|
13
|
-
import { useQueryClient } from "@tanstack/react-query";
|
|
14
|
-
import { orderEventsService } from "../realtime/order-events.service";
|
|
15
|
-
import { orderSnapshotsService } from "../sync/order-snapshots.service";
|
|
16
|
-
import { syncEngine } from "../sync/engine";
|
|
17
|
-
import { generateEventId } from "../utils/uuid";
|
|
18
|
-
import { getAppInstanceId } from "../adapters/devices";
|
|
19
|
-
import { getActiveBusiness } from "../adapters/business";
|
|
20
|
-
import { openSyncDatabase } from "../db/connection";
|
|
21
|
-
import { getOrderById } from "../db/operations";
|
|
22
|
-
const INVALIDATE_DEBOUNCE_MS = 400;
|
|
23
|
-
const UNKNOWN_ORDER_PULL_DEBOUNCE_MS = 1500;
|
|
24
|
-
/**
|
|
25
|
-
* Subscribe to real-time order events and invalidate all queries under the
|
|
26
|
-
* given root key prefix (e.g. ['seller'], ['pos'], ['kiosk']).
|
|
4
|
+
* The cloud → local event path now has a single owner: the global realtime
|
|
5
|
+
* bridge (`src/realtime/realtime-bridge.ts`), mounted once per app. It
|
|
6
|
+
* publishes every cloud event through the Sync Engine, and
|
|
7
|
+
* `useSyncReconciliation` reacts by journaling the event, patching the
|
|
8
|
+
* offline read model, invalidating the affected caches (debounced, 400 ms)
|
|
9
|
+
* and relaying the event to LAN peers.
|
|
27
10
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
11
|
+
* `useOrderRealtimeUpdates` used to be mounted per screen (POS, Orders,
|
|
12
|
+
* Kitchen, Display). That meant every mounted screen processed the same
|
|
13
|
+
* event — duplicated persistence, duplicated invalidations, duplicated
|
|
14
|
+
* fetches — and every screen that did NOT mount it (Dashboard, Settings)
|
|
15
|
+
* never saw another terminal's order at all. It is kept as a no-op so
|
|
16
|
+
* existing call sites keep compiling; new code must not use it.
|
|
31
17
|
*
|
|
32
|
-
*
|
|
18
|
+
* Invalidations stay coalesced: a reconnect/replay burst can deliver dozens
|
|
19
|
+
* of events at once, and an immediate invalidate-per-event forces repeated
|
|
20
|
+
* full re-renders of boards/grids — a major CPU spike on low-power POS
|
|
21
|
+
* hardware. One invalidation per ~400 ms window keeps the UI fresh without
|
|
22
|
+
* the render storm.
|
|
33
23
|
*/
|
|
34
|
-
|
|
35
|
-
const queryClient = useQueryClient();
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
let timer;
|
|
38
|
-
const pendingOrderIds = new Set();
|
|
39
|
-
const pending = { rootKey: false };
|
|
40
|
-
const ownDeviceId = getAppInstanceId();
|
|
41
|
-
const flush = () => {
|
|
42
|
-
timer = undefined;
|
|
43
|
-
if (pending.rootKey)
|
|
44
|
-
queryClient.invalidateQueries({ queryKey: rootKey });
|
|
45
|
-
for (const oid of pendingOrderIds) {
|
|
46
|
-
queryClient.invalidateQueries({ queryKey: [...rootKey, "order", oid] });
|
|
47
|
-
}
|
|
48
|
-
pendingOrderIds.clear();
|
|
49
|
-
pending.rootKey = false;
|
|
50
|
-
};
|
|
51
|
-
const unsub = orderEventsService.onOrderUpdates((event) => {
|
|
52
|
-
pending.rootKey = true;
|
|
53
|
-
if (event.order_id)
|
|
54
|
-
pendingOrderIds.add(event.order_id);
|
|
55
|
-
const payload = { ...event };
|
|
56
|
-
// Delivery events carry no business_id — the socket already filtered by
|
|
57
|
-
// business, so stamp the active one for read-model patching.
|
|
58
|
-
if (!payload.business_id && !payload.businessId) {
|
|
59
|
-
try {
|
|
60
|
-
const bid = getActiveBusiness()?.id;
|
|
61
|
-
if (bid)
|
|
62
|
-
payload.business_id = bid;
|
|
63
|
-
}
|
|
64
|
-
catch { }
|
|
65
|
-
}
|
|
66
|
-
const domainEvent = {
|
|
67
|
-
event_id: payload.event_id ?? generateEventId(),
|
|
68
|
-
event_type: event.type,
|
|
69
|
-
event_version: payload.event_version ?? "1.0",
|
|
70
|
-
timestamp: payload.timestamp ?? new Date().toISOString(),
|
|
71
|
-
device_id: payload.device_id ?? ownDeviceId,
|
|
72
|
-
user_id: payload.user_id ?? "system",
|
|
73
|
-
entity_id: event.order_id ?? "",
|
|
74
|
-
payload,
|
|
75
|
-
};
|
|
76
|
-
// Persist to durable offline read model (entity_snapshots table).
|
|
77
|
-
orderSnapshotsService.persistOrderEvent(domainEvent, "cloud").catch(() => { });
|
|
78
|
-
// order.created from another terminal is summary-only (no full body):
|
|
79
|
-
// invalidate alone can't surface it because SQLite has no row. Pull
|
|
80
|
-
// once (debounced) when the order is unknown locally. Event-driven —
|
|
81
|
-
// no polling.
|
|
82
|
-
if (event.type === "order.created" && event.order_id) {
|
|
83
|
-
const bid = payload.business_id;
|
|
84
|
-
if (bid) {
|
|
85
|
-
void (async () => {
|
|
86
|
-
try {
|
|
87
|
-
const db = await openSyncDatabase();
|
|
88
|
-
const known = await getOrderById(db, bid, event.order_id);
|
|
89
|
-
if (!known)
|
|
90
|
-
scheduleUnknownOrderPull(bid);
|
|
91
|
-
}
|
|
92
|
-
catch { }
|
|
93
|
-
})();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// Publish through the Sync Engine so useSyncReconciliation + LAN relay
|
|
97
|
-
// fire without waiting for the next poll.
|
|
98
|
-
try {
|
|
99
|
-
syncEngine.publish(domainEvent);
|
|
100
|
-
}
|
|
101
|
-
catch { }
|
|
102
|
-
if (!timer)
|
|
103
|
-
timer = setTimeout(flush, INVALIDATE_DEBOUNCE_MS);
|
|
104
|
-
});
|
|
105
|
-
return () => {
|
|
106
|
-
if (timer)
|
|
107
|
-
clearTimeout(timer);
|
|
108
|
-
unsub();
|
|
109
|
-
};
|
|
110
|
-
}, [queryClient, rootKey]);
|
|
111
|
-
}
|
|
24
|
+
import { useGlobalRealtimeBridge } from "../realtime/realtime-bridge";
|
|
112
25
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
26
|
+
* @deprecated Mount the global bridge once at the app root instead
|
|
27
|
+
* (`useRealtimeInvalidation` or `useSyncReconciliation`). Kept as a no-op
|
|
28
|
+
* so legacy call sites remain source-compatible.
|
|
115
29
|
*/
|
|
116
|
-
|
|
117
|
-
function scheduleUnknownOrderPull(businessId) {
|
|
118
|
-
if (unknownOrderPullTimers.has(businessId))
|
|
119
|
-
return;
|
|
120
|
-
const t = setTimeout(() => {
|
|
121
|
-
unknownOrderPullTimers.delete(businessId);
|
|
122
|
-
void import("../sync/sync-jobs.service")
|
|
123
|
-
.then((m) => m.pullRemoteOrders(businessId))
|
|
124
|
-
.catch(() => { });
|
|
125
|
-
}, UNKNOWN_ORDER_PULL_DEBOUNCE_MS);
|
|
126
|
-
unknownOrderPullTimers.set(businessId, t);
|
|
127
|
-
}
|
|
30
|
+
export function useOrderRealtimeUpdates(_rootKey) { }
|
|
128
31
|
/**
|
|
129
|
-
* Global realtime invalidation —
|
|
130
|
-
*
|
|
131
|
-
*
|
|
32
|
+
* Global realtime invalidation — mounts the single app-wide Phoenix
|
|
33
|
+
* subscription. Pair with `useSyncReconciliation` (which reacts to the
|
|
34
|
+
* published events) at the app root.
|
|
132
35
|
*/
|
|
133
|
-
export function useRealtimeInvalidation() {
|
|
36
|
+
export function useRealtimeInvalidation() {
|
|
37
|
+
useGlobalRealtimeBridge();
|
|
38
|
+
}
|