@teincfood/core 0.9.18 → 0.9.19

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.
@@ -408,4 +408,19 @@ export const MIGRATIONS = [
408
408
  `);
409
409
  },
410
410
  },
411
+ {
412
+ version: 11,
413
+ up: async (db) => {
414
+ // Terminal sync failure marker: set when a queued order:create or a
415
+ // transition dead-letters (e.g. the parent order was never created, or a
416
+ // transition 404s because the order does not exist server-side). The row
417
+ // is retained for audit instead of being silently dropped.
418
+ await db.execAsync(`
419
+ ALTER TABLE orders ADD COLUMN sync_failed_at TEXT;
420
+ `);
421
+ await db.execAsync(`
422
+ ALTER TABLE orders ADD COLUMN sync_error TEXT;
423
+ `);
424
+ },
425
+ },
411
426
  ];
@@ -67,6 +67,8 @@ export interface OrderRow {
67
67
  updated_at: string | null;
68
68
  hlc: string | null;
69
69
  deleted_at: string | null;
70
+ sync_failed_at?: string | null;
71
+ sync_error?: string | null;
70
72
  }
71
73
  export declare function insertOrder(db: SQLiteDatabase, order: import("../types/api/seller.api").SellerOrder & {
72
74
  hlc?: string;
@@ -84,6 +86,12 @@ export declare function listOrders(db: SQLiteDatabase, businessId: string, filte
84
86
  total: number;
85
87
  }>;
86
88
  export declare function updateOrderStatus(db: SQLiteDatabase, businessId: string, orderId: string, toStatus: string, hlc?: string): Promise<void>;
89
+ /**
90
+ * Flag an order whose queued work dead-lettered (parent create failed, or a
91
+ * transition 404'd because the server has no such order). The row is kept for
92
+ * audit rather than deleted; a later pull of the real order clears the flag.
93
+ */
94
+ export declare function markOrderSyncFailed(db: SQLiteDatabase, businessId: string, orderId: string, message: string): Promise<void>;
87
95
  export declare function updateOrderPayment(db: SQLiteDatabase, businessId: string, orderId: string, patch: {
88
96
  is_paid: boolean;
89
97
  paid_at: string | null;
@@ -228,6 +228,8 @@ function orderRowToSellerOrder(row) {
228
228
  paid_at: row.payment ? JSON.parse(row.payment).paid_at ?? null : null,
229
229
  inserted_at: row.inserted_at,
230
230
  updated_at: row.updated_at ?? row.inserted_at,
231
+ sync_failed_at: row.sync_failed_at ?? null,
232
+ sync_error: row.sync_error ?? null,
231
233
  };
232
234
  }
233
235
  export async function insertOrder(db, order) {
@@ -235,7 +237,7 @@ export async function insertOrder(db, order) {
235
237
  const updatedAt = order.updated_at ?? order.inserted_at;
236
238
  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)
237
239
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
238
- 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
240
+ 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
239
241
  WHERE excluded.hlc >= orders.hlc OR orders.hlc IS NULL;`, order.id, order.business_id, order.order_number, order.callout_number, order.status, order.order_type, order.is_pos ? 1 : 0, JSON.stringify(order.items), order.subtotal_minor, order.total_minor, order.tax_minor, JSON.stringify(order.tax_lines), JSON.stringify(order.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);
240
242
  notifyDbChange(order.business_id, "orders");
241
243
  }
@@ -286,6 +288,18 @@ export async function updateOrderStatus(db, businessId, orderId, toStatus, hlc)
286
288
  await db.runAsync(`UPDATE orders SET status = ?, hlc = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, toStatus, now, businessId, orderId, orderId);
287
289
  notifyDbChange(businessId, "orders");
288
290
  }
291
+ /**
292
+ * Flag an order whose queued work dead-lettered (parent create failed, or a
293
+ * transition 404'd because the server has no such order). The row is kept for
294
+ * audit rather than deleted; a later pull of the real order clears the flag.
295
+ */
296
+ export async function markOrderSyncFailed(db, businessId, orderId, message) {
297
+ try {
298
+ await db.runAsync(`UPDATE orders SET sync_failed_at = ?, sync_error = ? WHERE business_id = ? AND (id = ? OR order_number = ?);`, new Date().toISOString(), message, businessId, orderId, orderId);
299
+ notifyDbChange(businessId, "orders");
300
+ }
301
+ catch { }
302
+ }
289
303
  export async function updateOrderPayment(db, businessId, orderId, patch) {
290
304
  const row = await getOrderById(db, businessId, orderId);
291
305
  if (!row)
@@ -15,6 +15,18 @@ declare class CommandQueueService {
15
15
  private replaying;
16
16
  private syncEngine;
17
17
  setSyncEngine(engine: SyncEngine): void;
18
+ /**
19
+ * Create-before-transition gate.
20
+ *
21
+ * A transition can only be applied to an order that exists server-side. If
22
+ * the `order:create` for the same entity_id is still queued, the transition
23
+ * must wait behind it (never POST first — that is a guaranteed 404). If the
24
+ * create dead-lettered, the transition can never succeed and must be failed
25
+ * too. A missing create row means it already synced (rows are deleted on
26
+ * success), so the transition may proceed.
27
+ */
28
+ orderCreateGate(businessId: string, entityId: string): Promise<"none" | "pending" | "failed">;
29
+ private flagOrderSyncFailed;
18
30
  /**
19
31
  * Enqueue a command because no transport is currently available.
20
32
  * Returns an optimistic domain event so the caller can update the UI.
@@ -32,6 +44,11 @@ declare class CommandQueueService {
32
44
  * permanent-rejection paths.
33
45
  */
34
46
  private failPermanently;
47
+ /**
48
+ * Dead-letter every pending transition for an entity whose parent
49
+ * order:create permanently failed.
50
+ */
51
+ private failDependentTransitions;
35
52
  /**
36
53
  * Already-applied absorb: pull server truth, and if the order is already
37
54
  * at this command's target status, delete the command as success. The
@@ -9,10 +9,11 @@
9
9
  * - Provide optimistic domain events so the UI can render pending orders.
10
10
  */
11
11
  import { getSyncDatabase, openSyncDatabase } from "../db/connection";
12
- import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, } from "../db/operations";
12
+ import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandStatus, deleteCommand, insertEvent, markOrderSyncFailed, } from "../db/operations";
13
13
  import { syncLogger } from "../utils/sync-logger";
14
14
  import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { TransitionError, isReplayDeferred, isReplayNoTransport, TRANSITION_ERROR_CODES } from "./transition-errors";
16
+ import { isOrderCreate, isOrderTransition } from "./transition-endpoints";
16
17
  import { assertBusinessOpenForCreate } from "../utils/business-hours";
17
18
  import { extractApiErrorCode } from "../utils/error";
18
19
  import { generateEventId } from "../utils/uuid";
@@ -198,6 +199,51 @@ class CommandQueueService {
198
199
  }
199
200
  catch { }
200
201
  }
202
+ /**
203
+ * Create-before-transition gate.
204
+ *
205
+ * A transition can only be applied to an order that exists server-side. If
206
+ * the `order:create` for the same entity_id is still queued, the transition
207
+ * must wait behind it (never POST first — that is a guaranteed 404). If the
208
+ * create dead-lettered, the transition can never succeed and must be failed
209
+ * too. A missing create row means it already synced (rows are deleted on
210
+ * success), so the transition may proceed.
211
+ */
212
+ async orderCreateGate(businessId, entityId) {
213
+ try {
214
+ const db = getSyncDatabase();
215
+ const rows = await getPendingCommands(db, Number.MAX_SAFE_INTEGER, true);
216
+ let pending = false;
217
+ let failed = false;
218
+ for (const c of rows) {
219
+ if (c.business_id === businessId &&
220
+ c.entity_id === entityId &&
221
+ c.command_type === "order:create") {
222
+ if (c.status === "pending" || c.status === "in_flight")
223
+ pending = true;
224
+ else if (c.status === "failed")
225
+ failed = true;
226
+ }
227
+ }
228
+ if (pending)
229
+ return "pending";
230
+ if (failed)
231
+ return "failed";
232
+ return "none";
233
+ }
234
+ catch {
235
+ // Fail open: never brick a terminal because the DB read failed.
236
+ return "none";
237
+ }
238
+ }
239
+ async flagOrderSyncFailed(command, message) {
240
+ if (!command.entity_id)
241
+ return;
242
+ try {
243
+ await markOrderSyncFailed(getSyncDatabase(), command.business_id, command.entity_id, message);
244
+ }
245
+ catch { }
246
+ }
201
247
  /**
202
248
  * Enqueue a command because no transport is currently available.
203
249
  * Returns an optimistic domain event so the caller can update the UI.
@@ -263,6 +309,23 @@ class CommandQueueService {
263
309
  timestamp: command.timestamp,
264
310
  payload: command.payload,
265
311
  };
312
+ // Create-before-transition: never replay a transition whose parent
313
+ // order:create is still queued (it would 404), and fail it outright if
314
+ // the create already dead-lettered (it can never succeed).
315
+ if (isOrderTransition(command.command_type) && command.entity_id) {
316
+ const gate = await this.orderCreateGate(command.business_id, command.entity_id);
317
+ if (gate === "failed") {
318
+ await this.flagOrderSyncFailed(command, "Parent order creation failed");
319
+ await this.failPermanently(db, command, "Parent order creation failed", "parent-create-failed", {
320
+ code: "PARENT_CREATE_FAILED",
321
+ attempts: command.retry_count,
322
+ });
323
+ return;
324
+ }
325
+ if (gate === "pending") {
326
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "Parent order:create still queued — deferring transition");
327
+ }
328
+ }
266
329
  if (command.retry_count >= MAX_ATTEMPTS) {
267
330
  await updateCommandStatus(db, command.command_id, "failed", "Max retry count exceeded");
268
331
  return;
@@ -385,6 +448,19 @@ class CommandQueueService {
385
448
  }
386
449
  catch { }
387
450
  }
451
+ // Transition 404: the server has no such order. The absorb above already
452
+ // checked the duplicate-tap / replayed-race case; if it did not apply,
453
+ // replaying can never create the order — fail fast instead of burning
454
+ // the bounded 4xx retry budget on a resource that is gone.
455
+ if (status === 404 && isOrderTransition(command.command_type)) {
456
+ await this.flagOrderSyncFailed(command, `Order not found on server (${message})`);
457
+ await this.failPermanently(db, command, message, "404-order-missing", {
458
+ status,
459
+ code,
460
+ attempts: command.retry_count + 1,
461
+ });
462
+ return;
463
+ }
388
464
  // Every other failure is retryable on the shared 2s→5min backoff.
389
465
  //
390
466
  // 4xx (validation/permission/plan races) is bounded: a device stuck in
@@ -462,6 +538,52 @@ class CommandQueueService {
462
538
  }
463
539
  }
464
540
  catch { }
541
+ try {
542
+ if (isOrderTransition(command.command_type) || isOrderCreate(command.command_type)) {
543
+ await this.flagOrderSyncFailed(command, message);
544
+ }
545
+ }
546
+ catch { }
547
+ // A failed order:create strands every transition queued behind it — fail
548
+ // them too (they can never apply) instead of replaying them into a 404.
549
+ if (isOrderCreate(command.command_type) && command.entity_id) {
550
+ await this.failDependentTransitions(db, command, message);
551
+ }
552
+ }
553
+ /**
554
+ * Dead-letter every pending transition for an entity whose parent
555
+ * order:create permanently failed.
556
+ */
557
+ async failDependentTransitions(db, createCommand, message) {
558
+ try {
559
+ const pending = await getPendingCommands(db, Number.MAX_SAFE_INTEGER);
560
+ const rows = pending.filter((c) => c.business_id === createCommand.business_id &&
561
+ c.entity_id === createCommand.entity_id &&
562
+ c.command_type !== "order:create");
563
+ if (!rows.length)
564
+ return;
565
+ for (const row of rows) {
566
+ await updateCommandStatus(db, row.command_id, "failed", `parent_create_failed:${message}`);
567
+ reportSyncFailure({
568
+ phase: "outbox",
569
+ business_id: createCommand.business_id,
570
+ user_id: createCommand.user_id,
571
+ device_id: createCommand.device_id,
572
+ command_type: row.command_type,
573
+ command_id: row.command_id,
574
+ entity_id: createCommand.entity_id,
575
+ code: "PARENT_CREATE_FAILED",
576
+ message,
577
+ attempts: 0,
578
+ terminal: true,
579
+ });
580
+ }
581
+ syncLogger.warn("CommandQueueService", "Dead-lettered transitions behind failed create", {
582
+ entity_id: createCommand.entity_id,
583
+ count: rows.length,
584
+ });
585
+ }
586
+ catch { }
465
587
  }
466
588
  /**
467
589
  * Already-applied absorb: pull server truth, and if the order is already
@@ -24,6 +24,7 @@ import { getReferenceData } from "../reference/operations";
24
24
  import { openSyncDatabase } from "../db/connection";
25
25
  import { canBusinessSync } from "./sync-guard";
26
26
  import { TRANSITION_ERROR_CODES, TransitionError } from "./transition-errors";
27
+ import { isOrderTransition } from "./transition-endpoints";
27
28
  import { assertEntitledForCommand } from "../reference/entitlement-guard";
28
29
  import { noteRateLimited, extractRetryAfterMs } from "./rate-limit";
29
30
  function withTimeout(promise, ms) {
@@ -74,6 +75,30 @@ export class SyncEngine {
74
75
  throw new TransitionError(TRANSITION_ERROR_CODES.NO_ACTIVE_BUSINESS, "Sync requires active business and login — business_id missing or not authenticated");
75
76
  }
76
77
  syncLogger.command(command, "issued");
78
+ // Create-before-transition: a transition can only apply to an order that
79
+ // exists server-side. If its order:create is still queued, queue the
80
+ // transition behind it; if the create dead-lettered, discard the
81
+ // transition. POSTing a transition ahead of (or without) its create is a
82
+ // guaranteed 404 and must never happen.
83
+ if (isOrderTransition(command.command_type) && command.entity_id) {
84
+ const gate = await commandQueueService.orderCreateGate(command.business_id, command.entity_id);
85
+ if (gate === "failed") {
86
+ syncLogger.warn("SyncEngine", "Transition discarded — parent order:create failed", {
87
+ command_id: command.command_id,
88
+ command_type: command.command_type,
89
+ entity_id: command.entity_id,
90
+ });
91
+ throw new TransitionError(TRANSITION_ERROR_CODES.ORDER_NOT_FOUND, "Parent order creation failed — transition discarded");
92
+ }
93
+ if (gate === "pending") {
94
+ if (opts?.forReplay) {
95
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "Parent order:create still queued — deferring transition");
96
+ }
97
+ const event = await commandQueueService.enqueue(command);
98
+ this.broadcast(event);
99
+ return [event];
100
+ }
101
+ }
77
102
  // Perms guard (offline-capable): block before routing
78
103
  const required = CAPABILITY_BY_COMMAND[command.command_type];
79
104
  if (required) {
@@ -4,7 +4,7 @@
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 } from "../db/operations";
7
+ import { insertOrder, getOrderById, markOrderSyncFailed } from "../db/operations";
8
8
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
9
9
  import { getRefreshToken, setAuthTokens, clearAuth, subscribeAuth } from "../adapters/auth";
10
10
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
@@ -15,7 +15,7 @@ import { recordAttempt } from "../utils/attempt-stats";
15
15
  import { BackoffTracker } from "./backoff";
16
16
  import { generateOrderNumber } from "./order-number";
17
17
  import { orderSnapshotsService } from "./order-snapshots.service";
18
- import { getTransitionPath, DELIVERY_COMMANDS } from "./transition-endpoints";
18
+ import { getTransitionPath, DELIVERY_COMMANDS, isOrderTransition, isOrderCreate } from "./transition-endpoints";
19
19
  import { getQueuedStatus } from "../utils/order-transitions";
20
20
  import { extractApiErrorCode } from "../utils/error";
21
21
  import { canBusinessSync, isAuthenticated } from "./sync-guard";
@@ -294,6 +294,52 @@ async function absorbSyncJobIfApplied(db, businessId, entityId, commandType, job
294
294
  }
295
295
  return false;
296
296
  }
297
+ /**
298
+ * State of the order:create sync_job for an entity, used to enforce
299
+ * create-before-transition. A missing row means the create already synced
300
+ * (rows are retained as 'completed'/'failed', so 'none' means it never
301
+ * existed — a cloud-pulled order — and the transition may proceed).
302
+ */
303
+ async function syncJobCreateState(db, businessId, entityId) {
304
+ try {
305
+ const rows = await db.getAllAsync(`SELECT * FROM sync_jobs WHERE business_id = ?;`, businessId);
306
+ const creates = rows.filter((r) => r.entity_id === entityId && r.command_type === "order:create");
307
+ if (creates.some((r) => r.status === "pending"))
308
+ return "pending";
309
+ if (creates.some((r) => r.status === "failed"))
310
+ return "failed";
311
+ return "none";
312
+ }
313
+ catch {
314
+ return "none";
315
+ }
316
+ }
317
+ /** Dead-letter pending transitions for an entity whose order:create failed. */
318
+ async function failDependentSyncJobs(db, businessId, entityId, message) {
319
+ try {
320
+ const all = await db.getAllAsync(`SELECT * FROM sync_jobs WHERE business_id = ?;`, businessId);
321
+ const rows = all.filter((r) => r.entity_id === entityId && r.status === "pending" && r.command_type !== "order:create");
322
+ const now = new Date().toISOString();
323
+ for (const r of rows) {
324
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `parent_create_failed:${message}`, now, r.id);
325
+ reportSyncFailure({
326
+ phase: "sync_jobs",
327
+ business_id: businessId,
328
+ command_type: r.command_type,
329
+ command_id: r.id,
330
+ entity_id: entityId,
331
+ code: "PARENT_CREATE_FAILED",
332
+ message,
333
+ attempts: r.attempts,
334
+ terminal: true,
335
+ });
336
+ }
337
+ if (rows.length) {
338
+ syncLogger.warn("SyncJobs", "Dead-lettered transitions behind failed create", { businessId, entityId, count: rows.length });
339
+ }
340
+ }
341
+ catch { }
342
+ }
297
343
  export async function drainOne(businessId, entityId) {
298
344
  if (!businessId || !canBusinessSync(businessId)) {
299
345
  syncLogger.debug("SyncJobs", "drainOne early-return — no business/auth", { businessId, entityId });
@@ -340,6 +386,32 @@ export async function drainOne(businessId, entityId) {
340
386
  return;
341
387
  }
342
388
  const payload = JSON.parse(row.payload);
389
+ // Create-before-transition: a transition whose parent order:create has
390
+ // dead-lettered can never apply — fail it now rather than POSTing to a
391
+ // guaranteed 404. A still-pending create (defensive: FIFO normally drains
392
+ // it first) defers the transition.
393
+ if (isOrderTransition(row.command_type) && row.entity_id) {
394
+ const createState = await syncJobCreateState(db, businessId, row.entity_id);
395
+ if (createState === "failed") {
396
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, "parent_create_failed", new Date().toISOString(), row.id);
397
+ try {
398
+ await markOrderSyncFailed(db, businessId, row.entity_id, "Parent order creation failed");
399
+ }
400
+ catch { }
401
+ reportJobFailure(businessId, row, { code: "PARENT_CREATE_FAILED", message: "Parent order creation failed", attempts: row.attempts, terminal: true });
402
+ try {
403
+ const { notifyError } = await import("../adapters/notify");
404
+ notifyError(`Order creation failed — discarded a follow-up ${row.command_type}`);
405
+ }
406
+ catch { }
407
+ return;
408
+ }
409
+ if (createState === "pending") {
410
+ syncLogger.debug("SyncJobs", "Transition waiting on pending create", { businessId, entityId });
411
+ scheduleRetry(businessId);
412
+ return;
413
+ }
414
+ }
343
415
  // 1) PHX first (only when cloud reachable)
344
416
  let phxOk = false;
345
417
  if (isOnlineForPhx()) {
@@ -478,6 +550,28 @@ export async function drainOne(businessId, entityId) {
478
550
  }
479
551
  catch { }
480
552
  }
553
+ // Transition 404: the server has no such order. Absorb above checked the
554
+ // already-applied race; if it did not apply, replaying can never create the
555
+ // order — dead-letter immediately instead of burning the 4xx retry budget.
556
+ if (rest.status === 404 && isOrderTransition(row.command_type)) {
557
+ syncLogger.warn("SyncJobs", "Transition 404 — order missing, failing job", { businessId, entityId, command_type: row.command_type });
558
+ try {
559
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `404 ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
560
+ }
561
+ catch { }
562
+ try {
563
+ await markOrderSyncFailed(db, businessId, row.entity_id, `Order not found on server (${rest.message ?? rest.code ?? "404"})`);
564
+ }
565
+ catch { }
566
+ reportJobFailure(businessId, row, { status: 404, code: rest.code ?? "NOT_FOUND", message: rest.message, attempts: (row.attempts ?? 0) + 1, terminal: true });
567
+ clearRetry(businessId);
568
+ try {
569
+ const { notifyError } = await import("../adapters/notify");
570
+ notifyError(`Order no longer exists on server — dropped ${row.command_type}`);
571
+ }
572
+ catch { }
573
+ return;
574
+ }
481
575
  if (isBusinessClosed) {
482
576
  syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
483
577
  reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts: row.attempts, terminal: false });
@@ -505,6 +599,14 @@ export async function drainOne(businessId, entityId) {
505
599
  catch { }
506
600
  reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: true });
507
601
  clearRetry(businessId);
602
+ // A dead-lettered order:create strands every transition behind it.
603
+ if (isOrderCreate(row.command_type)) {
604
+ try {
605
+ await markOrderSyncFailed(db, businessId, row.entity_id, rest.message ?? "Order creation failed");
606
+ }
607
+ catch { }
608
+ await failDependentSyncJobs(db, businessId, row.entity_id, rest.message ?? "Order creation failed");
609
+ }
508
610
  try {
509
611
  const { notifyError } = await import("../adapters/notify");
510
612
  notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
@@ -11,6 +11,16 @@
11
11
  */
12
12
  /** Command types fulfilled by the delivery endpoints (strictly online). */
13
13
  export declare const DELIVERY_COMMANDS: Set<string>;
14
+ /**
15
+ * Order-scoped transitions. Every one of these operates on an order that must
16
+ * already exist server-side (the `order:create` for the same entity_id must
17
+ * have landed). Used to enforce create-before-transition ordering and to make
18
+ * a 404 on a transition terminal instead of a blind retry: a transition whose
19
+ * order is gone can never succeed by being replayed.
20
+ */
21
+ export declare const ORDER_TRANSITION_COMMANDS: Set<string>;
22
+ export declare function isOrderCreate(commandType: string): boolean;
23
+ export declare function isOrderTransition(commandType: string): boolean;
14
24
  /**
15
25
  * Build the REST path for a given command type and order/delivery ID.
16
26
  * Returns null for unknown command types AND for delivery commands, which
@@ -26,6 +26,30 @@ export const DELIVERY_COMMANDS = new Set([
26
26
  "order:assign_rider",
27
27
  "order:confirm_handover",
28
28
  ]);
29
+ /**
30
+ * Order-scoped transitions. Every one of these operates on an order that must
31
+ * already exist server-side (the `order:create` for the same entity_id must
32
+ * have landed). Used to enforce create-before-transition ordering and to make
33
+ * a 404 on a transition terminal instead of a blind retry: a transition whose
34
+ * order is gone can never succeed by being replayed.
35
+ */
36
+ export const ORDER_TRANSITION_COMMANDS = new Set([
37
+ "order:accept",
38
+ "order:reject",
39
+ "order:start_preparing",
40
+ "order:mark_ready",
41
+ "order:complete_pickup",
42
+ "order:cancel",
43
+ "order:confirm_cash",
44
+ "order:pay",
45
+ "payment:confirm_cash",
46
+ ]);
47
+ export function isOrderCreate(commandType) {
48
+ return commandType === "order:create";
49
+ }
50
+ export function isOrderTransition(commandType) {
51
+ return ORDER_TRANSITION_COMMANDS.has(commandType);
52
+ }
29
53
  /**
30
54
  * Build the REST path for a given command type and order/delivery ID.
31
55
  * Returns null for unknown command types AND for delivery commands, which
@@ -277,6 +277,15 @@ export function createMemoryDriver() {
277
277
  }
278
278
  return { lastInsertRowId: 0, changes: 1 };
279
279
  }
280
+ if (s.includes("UPDATE orders SET sync_failed_at = ?")) {
281
+ const [sync_failed_at, sync_error, business_id, id1, id2] = args;
282
+ const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
283
+ if (row) {
284
+ row.sync_failed_at = sync_failed_at;
285
+ row.sync_error = sync_error;
286
+ }
287
+ return { lastInsertRowId: 0, changes: 1 };
288
+ }
280
289
  // cleanup deletes for v7
281
290
  if (s.includes("DELETE FROM orders WHERE order_number = '[object Object]'")) {
282
291
  const before = tables.orders.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.18",
3
+ "version": "0.9.19",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",