@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.
@@ -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 { insertOrder, getOrderById, markOrderSyncFailed } from "../db/operations";
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: pull server truth, and if the
271
- * order is already at this command's target status, complete the job. Only
272
- * fires when the pull actually touched the row (hlc change) so a stale
273
- * local optimistic write can never delete work the server never applied.
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 before = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, businessId, entityId);
280
- await pullRemoteOrders(businessId);
281
- const afterHlc = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, businessId, entityId);
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
- const row = await db.getFirstAsync(`SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending' ORDER BY created_at LIMIT 1;`, businessId, entityId);
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
- await insertOrder(db, o);
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 insertOrder fail", { err: String(e) });
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);
@@ -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, "local");
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, "local");
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, "local");
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.
@@ -191,9 +191,12 @@ export function createMemoryDriver() {
191
191
  if (s.includes("INSERT INTO orders")) {
192
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;
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
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, customer_name, customer_phone, pos_payment_method, delivery_id, business, user, inserted_at, hlc });
199
202
  return { lastInsertRowId: 0, changes: 1 };
@@ -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
@@ -365,6 +368,15 @@ export function createMemoryDriver() {
365
368
  const row = tables.sync_jobs.find((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending");
366
369
  return row ?? null;
367
370
  }
371
+ // sync_jobs duplicate-command probe (transition coalescing)
372
+ if (s.includes("SELECT id FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND command_type = ?")) {
373
+ const [business_id, entity_id, command_type] = args;
374
+ const row = tables.sync_jobs.find((r) => r.business_id === business_id &&
375
+ r.entity_id === entity_id &&
376
+ r.command_type === command_type &&
377
+ r.status === "pending");
378
+ return row ?? null;
379
+ }
368
380
  // generic orders exists check for migrations backfill: SELECT * FROM orders WHERE business_id=? (not needed)
369
381
  return null;
370
382
  },
@@ -479,10 +491,19 @@ export function createMemoryDriver() {
479
491
  const [business_id] = args;
480
492
  const m = s.match(/LIMIT\s+(\d+)/);
481
493
  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 }));
494
+ 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
495
  rows = rows.slice(0, lim);
484
496
  return rows;
485
497
  }
498
+ // sync_jobs command list for a single entity (pending intent)
499
+ if (s.includes("FROM sync_jobs WHERE business_id = ? AND entity_id = ?")) {
500
+ const [business_id, entity_id] = args;
501
+ const rows = tables.sync_jobs
502
+ .filter((r) => r.business_id === business_id && r.entity_id === entity_id && r.status === "pending")
503
+ .sort((a, b) => (a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : a.rowid - b.rowid))
504
+ .map((r) => ({ command_type: r.command_type }));
505
+ return rows;
506
+ }
486
507
  if (s.includes("SELECT * FROM sync_jobs WHERE") || s.includes("SELECT id, command_type, status, created_at FROM sync_jobs")) {
487
508
  const [business_id] = args;
488
509
  const rows = tables.sync_jobs.filter((r) => r.business_id === business_id);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.22",
4
- "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
3
+ "version": "0.9.24",
4
+ "description": "TeincFood shared offline-first core \u2014 types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",