@teincfood/core 0.9.22 → 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.
@@ -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;
@@ -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
- const orderNumber = input.order_number?.toString() ?? generateOrderNumberForBusiness(businessId);
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 jobId = generateUUID();
199
- 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, `order:${action}`, orderId, JSON.stringify({ action, orderId }), now, now);
200
- syncLogger.debug("DomainOrders", "transition sync_jobs enqueued", { businessId, orderId, action, jobId });
201
- void drainOne(businessId, orderId).catch((e) => syncLogger.debug("DomainOrders", "drainOne after transition caught", { err: String(e) }));
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
- // blank-screen: snapshot fallback already tried — if still empty and online, fetch from cloud now so listing populates without manual refresh
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
- try {
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/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
40
- fetchStatus: import("@tanstack/react-query").FetchStatus;
41
- fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
42
- fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
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/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
74
- fetchStatus: import("@tanstack/react-query").FetchStatus;
75
- fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
76
- fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
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/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
109
- fetchStatus: import("@tanstack/react-query").FetchStatus;
110
- fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
111
- fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
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/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
142
- fetchStatus: import("@tanstack/react-query").FetchStatus;
143
- fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
144
- fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
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/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
176
- fetchStatus: import("@tanstack/react-query").FetchStatus;
177
- fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
178
- fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
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/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
210
- fetchStatus: import("@tanstack/react-query").FetchStatus;
211
- fetchNextPage: (options?: import("@tanstack/react-query").FetchNextPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
212
- fetchPreviousPage: (options?: import("@tanstack/react-query").FetchPreviousPageOptions) => Promise<import("@tanstack/react-query").InfiniteQueryObserverResult<import("@tanstack/react-query").InfiniteData<ListPage<BusinessDelivery>, unknown>, Error>>;
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 — bridge Phoenix WebSocket events into the
3
- * Sync Engine so useSyncReconciliation fires and React Query caches refresh.
2
+ * Realtime invalidation hooks.
4
3
  *
5
- * Both hooks are idempotent-safe and can be called from multiple screens.
6
- * Invalidations are debounced: a reconnect/replay burst can deliver dozens
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 source on low-power
9
- * POS hardware. Coalescing into one invalidation per ~400ms window keeps
10
- * the UI fresh without the render storm.
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
- * Subscribe to real-time order events and invalidate all queries under the
14
- * given root key prefix (e.g. ['seller'], ['pos'], ['kiosk']).
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(rootKey: readonly string[]): void;
29
+ export declare function useOrderRealtimeUpdates(_rootKey: readonly string[]): void;
23
30
  /**
24
- * Global realtime invalidation — currently handled entirely by
25
- * useOrderRealtimeUpdates + useSyncReconciliation. Kept as a no-op for
26
- * backward compatibility with any lingering call sites.
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 — bridge Phoenix WebSocket events into the
3
- * Sync Engine so useSyncReconciliation fires and React Query caches refresh.
2
+ * Realtime invalidation hooks.
4
3
  *
5
- * Both hooks are idempotent-safe and can be called from multiple screens.
6
- * Invalidations are debounced: a reconnect/replay burst can deliver dozens
7
- * of events at once, and an immediate invalidate-per-event forces repeated
8
- * full re-renders of boards/grids — a major CPU spike source on low-power
9
- * POS hardware. Coalescing into one invalidation per ~400ms window keeps
10
- * the UI fresh without the render storm.
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
- * Each event is also persisted to the durable offline read model so order
29
- * history and states survive a restart while offline, and published through
30
- * the Sync Engine so useSyncReconciliation + LAN relay fire without polling.
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
- * Call once per screen that needs live updates (POS, Orders, Kitchen, etc.).
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
- export function useOrderRealtimeUpdates(rootKey) {
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
- * Debounced pull for remotely-created orders (one timer per business).
114
- * Module-level so bursts across screens coalesce into a single pull.
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
- const unknownOrderPullTimers = new Map();
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 — currently handled entirely by
130
- * useOrderRealtimeUpdates + useSyncReconciliation. Kept as a no-op for
131
- * backward compatibility with any lingering call sites.
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
+ }