@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.
- package/dist/db/migrations.js +15 -0
- package/dist/db/operations.d.ts +8 -0
- package/dist/db/operations.js +15 -1
- package/dist/sync/command-queue.service.d.ts +17 -0
- package/dist/sync/command-queue.service.js +123 -1
- package/dist/sync/engine.js +25 -0
- package/dist/sync/sync-jobs.service.js +104 -2
- package/dist/sync/transition-endpoints.d.ts +10 -0
- package/dist/sync/transition-endpoints.js +24 -0
- package/dist/test-helpers/memory-driver.js +9 -0
- package/package.json +1 -1
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
];
|
package/dist/db/operations.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/operations.js
CHANGED
|
@@ -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
|
package/dist/sync/engine.js
CHANGED
|
@@ -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