@teincfood/core 0.9.9 → 0.9.11

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.
@@ -9,7 +9,7 @@ export interface InsertCommandInput {
9
9
  }
10
10
  export declare function insertCommand(db: SQLiteDatabase, input: InsertCommandInput): Promise<PersistedCommand>;
11
11
  export declare function getPendingCommands(db: SQLiteDatabase, limit?: number): Promise<PersistedCommand[]>;
12
- export declare function updateCommandStatus(db: SQLiteDatabase, commandId: string, status: PersistedCommand["status"], error?: string): Promise<void>;
12
+ export declare function updateCommandStatus(db: SQLiteDatabase, commandId: string, status: PersistedCommand["status"], error?: string, incrementRetry?: boolean): Promise<void>;
13
13
  export declare function markCommandInFlight(db: SQLiteDatabase, commandId: string): Promise<void>;
14
14
  export declare function deleteCommand(db: SQLiteDatabase, commandId: string): Promise<void>;
15
15
  export declare function clearCompletedCommands(db: SQLiteDatabase, olderThanHours?: number): Promise<void>;
@@ -33,7 +33,7 @@ export async function getPendingCommands(db, limit = 100) {
33
33
  `, limit);
34
34
  return rows.map(rowToPersistedCommand);
35
35
  }
36
- export async function updateCommandStatus(db, commandId, status, error) {
36
+ export async function updateCommandStatus(db, commandId, status, error, incrementRetry) {
37
37
  const now = new Date().toISOString();
38
38
  if (status === "failed") {
39
39
  await db.runAsync(`
@@ -46,6 +46,11 @@ export async function updateCommandStatus(db, commandId, status, error) {
46
46
  else if (status === "completed") {
47
47
  await db.runAsync("UPDATE commands SET status = ?, error = NULL, updated_at = ? WHERE command_id = ?;", status, now, commandId);
48
48
  }
49
+ else if (incrementRetry) {
50
+ // Bounded 4xx retries count attempts without failing the row, so
51
+ // MAX_RETRIES caps blind retries while 5xx/network stay indefinite.
52
+ await db.runAsync("UPDATE commands SET status = ?, error = ?, retry_count = retry_count + 1, updated_at = ? WHERE command_id = ?;", status, error ?? null, now, commandId);
53
+ }
49
54
  else {
50
55
  await db.runAsync("UPDATE commands SET status = ?, error = ?, updated_at = ? WHERE command_id = ?;", status, error ?? null, now, commandId);
51
56
  }
@@ -13,7 +13,7 @@ import { applyReferenceChangeEvent } from "../reference/events";
13
13
  import { openSyncDatabase } from "../db/connection";
14
14
  import { insertOrder } from "../db/operations";
15
15
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
16
- import { getConnectivityState, setConnectivityState } from "../adapters/connectivity";
16
+ import { getConnectivityState } from "../adapters/connectivity";
17
17
  import { deriveLanSecret as deriveSecretCore } from "./security";
18
18
  const KEEP_AWAKE_TAG = "TeincLocalNode";
19
19
  const RECONNECT_BASE = 1000;
@@ -742,11 +742,10 @@ class LocalNodeServiceCore {
742
742
  rejectionStatus < 500) {
743
743
  throw error;
744
744
  }
745
+ // Flag-neutral (probe owns isCloudReachable): a failed forward is
746
+ // never evidence the cloud is down. Fall back to the queue; the
747
+ // probe detects real outages and replays trigger on recovery.
745
748
  syncLogger.warn("LocalNodeService", "Cloud forward failed, queueing", { error: String(error) });
746
- try {
747
- setConnectivityState({ isCloudReachable: "unreachable" });
748
- }
749
- catch { }
750
749
  event = await commandQueueService.enqueue(command);
751
750
  }
752
751
  }
@@ -49,6 +49,21 @@ export interface MenuItemEnvelopeInput extends MenuItemRecordInput {
49
49
  /** Extra wire-only keys (e.g. entity id). */
50
50
  wireExtra?: Record<string, unknown>;
51
51
  }
52
+ /**
53
+ * Image value for a menu-item UPDATE wire payload. The server is the source
54
+ * of truth for the stored key — never re-send a loaded URL and risk
55
+ * overwriting the key with a URL-shaped value. Returns `undefined` when the
56
+ * caller must OMIT `image_url` (server keeps its key untouched):
57
+ * - fresh upload → the bare S3 key;
58
+ * - user removed the photo (had one, now empty) → explicit null (clear it);
59
+ * - loaded an un-storable scheme (tfimg:/blob:/file: poison) → null (heal);
60
+ * - otherwise (loaded URL or bare key, no new upload) → omit (preserve).
61
+ */
62
+ export declare function imageUrlForUpdateWire(opts: {
63
+ freshKey?: string | null;
64
+ loadedUrl?: string | null;
65
+ hadImage: boolean;
66
+ }): string | null | undefined;
52
67
  /**
53
68
  * Build the { wire, cacheRecord } pair for a menu item write. Callers pass
54
69
  * absolute variant prices (backend model) — the form layer owns the field.
@@ -114,13 +114,34 @@ export function getCachedCategoryName(qc, businessId, categoryId) {
114
114
  }
115
115
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
116
116
  /**
117
- * A variant/extra id is only safe to send when it is a real backend UUID.
118
- * Form-local ids (e.g. `local-<ts>`) must be omitted: the backend treats a
117
+ * A variant/extra id is only safe to send when it is a real UUID. Single
118
+ * global id space: clients generate UUIDs up front and the backend stores
119
+ * them verbatim. Non-UUID junk must be omitted: the backend treats a
119
120
  * missing/empty id as "insert new", but Repo.get/3 raises on a non-UUID.
120
121
  */
121
122
  function wireId(id) {
122
123
  return id && UUID_RE.test(id) ? id : undefined;
123
124
  }
125
+ /**
126
+ * Image value for a menu-item UPDATE wire payload. The server is the source
127
+ * of truth for the stored key — never re-send a loaded URL and risk
128
+ * overwriting the key with a URL-shaped value. Returns `undefined` when the
129
+ * caller must OMIT `image_url` (server keeps its key untouched):
130
+ * - fresh upload → the bare S3 key;
131
+ * - user removed the photo (had one, now empty) → explicit null (clear it);
132
+ * - loaded an un-storable scheme (tfimg:/blob:/file: poison) → null (heal);
133
+ * - otherwise (loaded URL or bare key, no new upload) → omit (preserve).
134
+ */
135
+ export function imageUrlForUpdateWire(opts) {
136
+ if (opts.freshKey)
137
+ return opts.freshKey;
138
+ const loaded = opts.loadedUrl ?? null;
139
+ if (!loaded)
140
+ return opts.hadImage ? null : undefined;
141
+ if (toStorableImageUrl(loaded) === null)
142
+ return null;
143
+ return undefined;
144
+ }
124
145
  /**
125
146
  * Build the { wire, cacheRecord } pair for a menu item write. Callers pass
126
147
  * absolute variant prices (backend model) — the form layer owns the field.
@@ -19,6 +19,11 @@ import { anchorServerTime } from "./entitlement-clock";
19
19
  import { isServerAuthoritativeKey } from "./entitlements";
20
20
  import { fetchReferenceVersions, fetchReferenceData, fetchReferenceChanges, } from "./api";
21
21
  const DEFAULT_SYNC_KEYS = [
22
+ // Server-authoritative plan proof first: every gated command evaluates
23
+ // against this, so it must never starve behind slow/heavy keys (menu
24
+ // pagination). Capabilities second for the same reason (nav + guards).
25
+ "subscription:entitlements",
26
+ "capabilities",
22
27
  "menu:items",
23
28
  "menu:categories",
24
29
  "roles",
@@ -28,8 +33,6 @@ const DEFAULT_SYNC_KEYS = [
28
33
  "operating-hours",
29
34
  "delivery-settings",
30
35
  "payment-settings",
31
- "subscription:entitlements",
32
- "capabilities",
33
36
  ];
34
37
  class ReferenceSyncService {
35
38
  constructor() {
@@ -193,6 +196,11 @@ class ReferenceSyncService {
193
196
  await anchorServerTime(envelope.server_time);
194
197
  }
195
198
  catch { }
199
+ syncLogger.info("ReferenceSync", `Server-authoritative ${key} cached`, {
200
+ businessId,
201
+ version: envelope.version,
202
+ server_time: envelope.server_time,
203
+ });
196
204
  return wrote;
197
205
  }
198
206
  await mergeReferenceData(db, businessId, key, payload, hlc, `evt_${hlc}`);
@@ -368,7 +376,7 @@ class ReferenceSyncService {
368
376
  hash = (hash << 5) - hash + char;
369
377
  hash |= 0;
370
378
  }
371
- return `local-${Math.abs(hash).toString(16)}`;
379
+ return `hash-${Math.abs(hash).toString(16)}`;
372
380
  }
373
381
  async getCached(businessId, key) {
374
382
  const db = await openSyncDatabase();
@@ -24,6 +24,20 @@ declare class CommandQueueService {
24
24
  * Try to execute a single pending command through the current transport.
25
25
  */
26
26
  processCommand(command: PersistedCommand): Promise<void>;
27
+ /**
28
+ * Mark a command permanently failed: loud toast + optimistic revert, then
29
+ * replay continues to the next command. Shared by the exhausted-retry and
30
+ * permanent-rejection paths.
31
+ */
32
+ private failPermanently;
33
+ /**
34
+ * Already-applied absorb: pull server truth, and if the order is already
35
+ * at this command's target status, delete the command as success. The
36
+ * absorb ONLY fires when the pull actually touched the row (hlc change) —
37
+ * otherwise a stale local optimistic write could mask a genuine race and
38
+ * delete work the server never applied.
39
+ */
40
+ private absorbIfApplied;
27
41
  /**
28
42
  * Replay all pending commands in order. Stops on the first failure to keep
29
43
  * ordering consistent; the next replay will retry from the failed command.
@@ -19,7 +19,18 @@ import { generateEventId } from "../utils/uuid";
19
19
  import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrderNumber, } from "./order-number";
20
20
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
21
21
  import { canBusinessSync } from "./sync-guard";
22
+ import { backoffDelayMs } from "./backoff";
22
23
  const MAX_RETRIES = 5;
24
+ /**
25
+ * 4xx that deserve another chance: transient races and rate limits, never
26
+ * permanent verdicts. Anything else 4xx (400 validation, 401 session,
27
+ * 402 plan, 403 forbidden) fails fast with a toast. 409 ORDER_NUMBER_TAKEN
28
+ * has its own regen path below and is not capped (every attempt mints a
29
+ * new order number, so it is never a blind retry).
30
+ */
31
+ const RETRYABLE_4XX = new Set([404, 408, 409, 422, 429]);
32
+ /** In-memory next-eligible time per command (shared 2s→5min curve). */
33
+ const retryNotBefore = new Map();
23
34
  const QUEUED_STATUS_BY_COMMAND = {
24
35
  "order:accept": "accepted",
25
36
  "order:reject": "cancelled",
@@ -268,6 +279,12 @@ class CommandQueueService {
268
279
  await updateCommandStatus(db, command.command_id, "failed", "Max retry count exceeded");
269
280
  return;
270
281
  }
282
+ // Backoff pacing for retryable failures: the next replay skips (defers)
283
+ // this command until its slot, preserving FIFO without a hot loop.
284
+ const notBefore = retryNotBefore.get(command.command_id);
285
+ if (notBefore !== undefined && notBefore > Date.now()) {
286
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, "In retry backoff, deferring queued command");
287
+ }
271
288
  if (!this.syncEngine) {
272
289
  throw new Error("CommandQueueService: SyncEngine not initialized");
273
290
  }
@@ -323,31 +340,45 @@ class CommandQueueService {
323
340
  await updateCommandStatus(db, command.command_id, "failed", `order_number_taken:${message}`);
324
341
  throw error;
325
342
  }
326
- const isNonRetryable = typeof apiErr?.status === "number" &&
327
- apiErr.status >= 400 &&
328
- apiErr.status < 500;
329
- if (isNonRetryable) {
330
- await updateCommandStatus(db, command.command_id, "failed", `permanent:${message}`);
331
- syncLogger.warn("CommandQueueService", "Queued command rejected by server (not retrying)", {
332
- command_id: command.command_id,
333
- command_type: command.command_type,
334
- status: apiErr.status,
335
- error: message,
336
- });
337
- // Loud toast + revert: the optimistic local write is rolled back so
338
- // the UI never shows what the backend refused.
343
+ const status = typeof apiErr?.status === "number" ? apiErr.status : undefined;
344
+ // Already-applied absorb: a 422/404 against an order the server
345
+ // already moved (duplicate tap, earlier drain won the race) is
346
+ // success, not failure. Verified against server truth via pull
347
+ // (HLC-guarded: pulled rows overwrite local), never the optimistic row.
348
+ if (status === 422 ||
349
+ code === "INVALID_TRANSITION" ||
350
+ (status === 404 && command.command_type !== "order:create")) {
339
351
  try {
340
- const { notifyError } = await import("../adapters/notify");
341
- notifyError(`Server rejected ${command.command_type}: ${message}`);
352
+ if (await this.absorbIfApplied(command))
353
+ return;
342
354
  }
343
355
  catch { }
344
- try {
345
- if (command.command_type.startsWith("menu:") && command.entity_id) {
346
- const { revertMenuCommand } = await import("../reference/menu-apply");
347
- await revertMenuCommand(command.business_id, command.command_type, command.entity_id);
348
- }
356
+ }
357
+ // Retryable 4xx (FIFO races, rate limits): back off on the shared
358
+ // 2s→5min curve, then fail loudly after MAX_RETRIES. Throw a
359
+ // transport-neutral deferral so replay STOPS here (FIFO ordering) —
360
+ // rethrowing the 4xx itself would make the loop `continue` past it.
361
+ if (status !== undefined && RETRYABLE_4XX.has(status)) {
362
+ const attempts = command.retry_count + 1;
363
+ if (attempts >= MAX_RETRIES) {
364
+ await this.failPermanently(db, command, message, `retryable 4xx exhausted after ${attempts} attempts`);
365
+ return;
349
366
  }
350
- catch { }
367
+ retryNotBefore.set(command.command_id, Date.now() + backoffDelayMs(command.retry_count));
368
+ await updateCommandStatus(db, command.command_id, "pending", `retry_4xx:${message}`, true);
369
+ syncLogger.warn("CommandQueueService", "Retryable 4xx — backing off", {
370
+ command_id: command.command_id,
371
+ command_type: command.command_type,
372
+ status,
373
+ attempt: attempts,
374
+ });
375
+ throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, `Retryable ${status} on ${command.command_type}, backing off`);
376
+ }
377
+ const isNonRetryable = status !== undefined &&
378
+ status >= 400 &&
379
+ status < 500;
380
+ if (isNonRetryable) {
381
+ await this.failPermanently(db, command, message, "permanent 4xx");
351
382
  // Do not throw — allow replay to continue to next command.
352
383
  return;
353
384
  }
@@ -360,6 +391,66 @@ class CommandQueueService {
360
391
  throw error;
361
392
  }
362
393
  }
394
+ /**
395
+ * Mark a command permanently failed: loud toast + optimistic revert, then
396
+ * replay continues to the next command. Shared by the exhausted-retry and
397
+ * permanent-rejection paths.
398
+ */
399
+ async failPermanently(db, command, message, reason) {
400
+ await updateCommandStatus(db, command.command_id, "failed", `permanent:${message}`);
401
+ syncLogger.warn("CommandQueueService", `Queued command failed (${reason})`, {
402
+ command_id: command.command_id,
403
+ command_type: command.command_type,
404
+ error: message,
405
+ });
406
+ try {
407
+ const { notifyError } = await import("../adapters/notify");
408
+ notifyError(`Server rejected ${command.command_type}: ${message}`);
409
+ }
410
+ catch { }
411
+ try {
412
+ if (command.command_type.startsWith("menu:") && command.entity_id) {
413
+ const { revertMenuCommand } = await import("../reference/menu-apply");
414
+ await revertMenuCommand(command.business_id, command.command_type, command.entity_id);
415
+ }
416
+ }
417
+ catch { }
418
+ }
419
+ /**
420
+ * Already-applied absorb: pull server truth, and if the order is already
421
+ * at this command's target status, delete the command as success. The
422
+ * absorb ONLY fires when the pull actually touched the row (hlc change) —
423
+ * otherwise a stale local optimistic write could mask a genuine race and
424
+ * delete work the server never applied.
425
+ */
426
+ async absorbIfApplied(command) {
427
+ const target = QUEUED_STATUS_BY_COMMAND[command.command_type];
428
+ if (!target || !command.entity_id)
429
+ return false;
430
+ try {
431
+ const db = getSyncDatabase();
432
+ const before = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, command.business_id, command.entity_id);
433
+ const { pullRemoteOrders } = await import("./sync-jobs.service");
434
+ await pullRemoteOrders(command.business_id);
435
+ const afterHlc = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, command.business_id, command.entity_id);
436
+ if (!afterHlc || afterHlc.hlc === before?.hlc)
437
+ return false;
438
+ const { getOrderById } = await import("../db/operations");
439
+ const order = await getOrderById(db, command.business_id, command.entity_id);
440
+ if (order && order.status === target) {
441
+ await deleteCommand(db, command.command_id);
442
+ retryNotBefore.delete(command.command_id);
443
+ syncLogger.info("CommandQueueService", "Transition already applied on server, absorbing", {
444
+ command_id: command.command_id,
445
+ command_type: command.command_type,
446
+ status: target,
447
+ });
448
+ return true;
449
+ }
450
+ }
451
+ catch { }
452
+ return false;
453
+ }
363
454
  /**
364
455
  * Replay all pending commands in order. Stops on the first failure to keep
365
456
  * ordering consistent; the next replay will retry from the failed command.
@@ -14,7 +14,7 @@
14
14
  import { restTransport } from "./transport-rest";
15
15
  import { localNodeTransport } from "./transport-local-node";
16
16
  import { commandQueueService } from "./command-queue.service";
17
- import { getConnectivityState, setConnectivityState } from "../adapters/connectivity";
17
+ import { getConnectivityState } from "../adapters/connectivity";
18
18
  import { ApiError } from "../adapters/http";
19
19
  import { errorCodeOf } from "./transition-errors";
20
20
  import { syncLogger } from "../utils/sync-logger";
@@ -155,9 +155,9 @@ export class SyncEngine {
155
155
  syncLogger.command(command, "completed", {
156
156
  event_count: events.length,
157
157
  });
158
- // Cloud just worked: it is reachable again. Mark it so future commands
159
- // go cloud-first, and replay anything queued during the outage.
160
- setConnectivityState({ isCloudReachable: "reachable" });
158
+ // Cloud just worked: replay anything queued during the outage.
159
+ // Flag-neutral: only the health probe writes isCloudReachable — a
160
+ // single success here is not its decision to make either.
161
161
  this.replayQueuedCommands().catch((error) => {
162
162
  syncLogger.error("SyncEngine", "Replay after cloud success failed", {
163
163
  error: String(error),
@@ -199,13 +199,16 @@ export class SyncEngine {
199
199
  });
200
200
  throw error;
201
201
  }
202
- // Cloud is slow or unreachable: mark it and fall back to the Local
203
- // Node or the outbox so the terminal stays responsive.
202
+ // Cloud is slow or unreachable: fall back to the Local Node or the
203
+ // outbox so the terminal stays responsive. Flag-neutral: only the
204
+ // health probe writes isCloudReachable — a failed request is never
205
+ // evidence the cloud is down (it may be a logic rejection without a
206
+ // numeric status, e.g. TransitionError). The probe detects real
207
+ // outages; queued work replays when it reports recovery.
204
208
  syncLogger.warn("SyncEngine", "Cloud command timed out, falling back", {
205
209
  command_type: command.command_type,
206
210
  error: String(error),
207
211
  });
208
- setConnectivityState({ isCloudReachable: "unreachable" });
209
212
  strategy = this.resolveStrategy(command);
210
213
  if (strategy === "queue") {
211
214
  // Replays must never re-enqueue-then-delete (the command would eat
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Core Health Probe — periodic cloud reachability check.
3
3
  *
4
- * Probes `GET /health` using the injected HttpClient with a short timeout.
5
- * On success: marks cloud reachable (triggers sync replay via connectivity
6
- * subscribers). On failure: marks unreachable and schedules a retry with
7
- * exponential backoff (2s → 5min cap, ±20% jitter).
4
+ * SOLE OWNER of the `isCloudReachable` flag: success marks reachable
5
+ * (triggers sync replay via connectivity subscribers), failure marks
6
+ * unreachable and schedules a retry with exponential backoff (15s floor →
7
+ * 5min cap, ±20% jitter). No request path may write the flag — a single
8
+ * failed request is never evidence the cloud is down (see SyncEngine,
9
+ * LocalNodeService), so all failure handling there is flag-neutral.
8
10
  *
9
11
  * Started automatically by `createTeincCore()`. Host apps should not need
10
12
  * to call this directly.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Core Health Probe — periodic cloud reachability check.
3
3
  *
4
- * Probes `GET /health` using the injected HttpClient with a short timeout.
5
- * On success: marks cloud reachable (triggers sync replay via connectivity
6
- * subscribers). On failure: marks unreachable and schedules a retry with
7
- * exponential backoff (2s → 5min cap, ±20% jitter).
4
+ * SOLE OWNER of the `isCloudReachable` flag: success marks reachable
5
+ * (triggers sync replay via connectivity subscribers), failure marks
6
+ * unreachable and schedules a retry with exponential backoff (15s floor →
7
+ * 5min cap, ±20% jitter). No request path may write the flag — a single
8
+ * failed request is never evidence the cloud is down (see SyncEngine,
9
+ * LocalNodeService), so all failure handling there is flag-neutral.
8
10
  *
9
11
  * Started automatically by `createTeincCore()`. Host apps should not need
10
12
  * to call this directly.
@@ -62,12 +64,20 @@ export async function probeCloudHealth() {
62
64
  return true;
63
65
  }
64
66
  catch {
65
- // Don't mark unreachable here — the sync engine already handles that
66
- // when a command times out. This probe only detects recovery.
67
- // Log and backoff so we re-check later.
68
- syncLogger.debug("HealthProbe", "Probe failed — will retry", {
69
- previous: getConnectivityState().isCloudReachable,
70
- });
67
+ // Sole owner rule: the probe is the only writer of isCloudReachable.
68
+ // A failed probe IS evidence (dedicated check, short timeout), unlike a
69
+ // failed business request — mark unreachable so readers stop attempting
70
+ // cloud work until recovery is detected. Backoff bounds the retry rate.
71
+ const was = getConnectivityState().isCloudReachable;
72
+ if (was !== "unreachable") {
73
+ setCloudReachable("unreachable");
74
+ syncLogger.info("HealthProbe", "Cloud unreachable", { previous: was });
75
+ }
76
+ else {
77
+ syncLogger.debug("HealthProbe", "Probe failed — still unreachable, will retry", {
78
+ previous: was,
79
+ });
80
+ }
71
81
  scheduleRetry();
72
82
  return false;
73
83
  }
@@ -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 } from "../db/operations";
7
+ import { insertOrder, getOrderById } 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";
@@ -16,11 +16,14 @@ import { BackoffTracker } from "./backoff";
16
16
  import { generateOrderNumber } from "./order-number";
17
17
  import { orderSnapshotsService } from "./order-snapshots.service";
18
18
  import { getTransitionPath, DELIVERY_COMMANDS } from "./transition-endpoints";
19
+ import { getQueuedStatus } from "../utils/order-transitions";
19
20
  import { extractApiErrorCode } from "../utils/error";
20
21
  import { canBusinessSync, isAuthenticated } from "./sync-guard";
21
22
  const inflight = new Map();
22
23
  const retryTimers = new Map();
23
24
  const backoffTracker = new BackoffTracker();
25
+ /** Blind 4xx retries stop here, loudly. 5xx/network retry indefinitely. */
26
+ const MAX_SYNC_JOB_ATTEMPTS = 5;
24
27
  function isOnline() {
25
28
  const s = getConnectivityState();
26
29
  return s.isOnline && s.isCloudReachable !== "unreachable";
@@ -215,6 +218,34 @@ async function tryRest(businessId, payload, commandType) {
215
218
  };
216
219
  }
217
220
  }
221
+ /**
222
+ * Already-applied absorb for sync_jobs rows: pull server truth, and if the
223
+ * order is already at this command's target status, complete the job. Only
224
+ * fires when the pull actually touched the row (hlc change) so a stale
225
+ * local optimistic write can never delete work the server never applied.
226
+ */
227
+ async function absorbSyncJobIfApplied(db, businessId, entityId, commandType, jobId) {
228
+ const target = getQueuedStatus(commandType);
229
+ if (!target || !entityId)
230
+ return false;
231
+ const before = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, businessId, entityId);
232
+ await pullRemoteOrders(businessId);
233
+ const afterHlc = await db.getFirstAsync(`SELECT hlc FROM orders WHERE business_id = ? AND id = ?;`, businessId, entityId);
234
+ if (!afterHlc || afterHlc.hlc === before?.hlc)
235
+ return false;
236
+ const order = await getOrderById(db, businessId, entityId);
237
+ if (order && order.status === target) {
238
+ await db.runAsync(`UPDATE sync_jobs SET status='completed', updated_at=? WHERE id=?;`, new Date().toISOString(), jobId);
239
+ syncLogger.info("SyncJobs", "Transition already applied on server, absorbing job", {
240
+ businessId,
241
+ entityId,
242
+ commandType,
243
+ status: target,
244
+ });
245
+ return true;
246
+ }
247
+ return false;
248
+ }
218
249
  export async function drainOne(businessId, entityId) {
219
250
  if (!businessId || !canBusinessSync(businessId)) {
220
251
  syncLogger.debug("SyncJobs", "drainOne early-return — no business/auth", { businessId, entityId });
@@ -355,14 +386,15 @@ export async function drainOne(businessId, entityId) {
355
386
  return;
356
387
  }
357
388
  // Permanent 4xx: mark failed to stop the retry loop.
358
- // 404 is only retryable for order:create (FIFO race: order not yet created
359
- // on backend). For transitions, 404 means the order was deleted or never
360
- // synced — permanent. 409 handled above for RRR regen. 401/408/429 need
361
- // special handling below. Backend codes are exact (no message matching).
389
+ // Retryable (bounded, 5 attempts): 401 (refresh owns it below), 408,
390
+ // 409, 422, 429, any 404 (FIFO race: earlier command still in flight),
391
+ // BUSINESS_CLOSED (pending-by-design). Everything else 4xx (400
392
+ // validation, 402 plan, 403 forbidden) fails fast. Backend codes are
393
+ // exact (no message matching).
362
394
  const isBusinessClosed = rest.code === "BUSINESS_CLOSED" || rest.code === "HOURS_NOT_SYNCED";
363
395
  const is4xx = rest.status && rest.status >= 400 && rest.status < 500;
364
- const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409 || rest.status === 429
365
- || (rest.status === 404 && row.command_type === "order:create")
396
+ const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409
397
+ || rest.status === 422 || rest.status === 429 || rest.status === 404
366
398
  || isBusinessClosed;
367
399
  if (is4xx && !isRetryable4xx) {
368
400
  syncLogger.warn("SyncJobs", "Permanent 4xx — marking job failed to stop retry loop", { businessId, entityId, status: rest.status, code: rest.code, message: rest.message });
@@ -385,6 +417,37 @@ export async function drainOne(businessId, entityId) {
385
417
  }
386
418
  return;
387
419
  }
420
+ // Already-applied absorb for transitions: the server already moved the
421
+ // order (duplicate tap, earlier drain won the race) — complete the job
422
+ // instead of failing it. Server truth only (hlc-guarded pull), never the
423
+ // optimistic row.
424
+ if ((rest.status === 422 || rest.code === "INVALID_TRANSITION" ||
425
+ (rest.status === 404 && row.command_type !== "order:create")) &&
426
+ getQueuedStatus(row.command_type) !== null) {
427
+ try {
428
+ if (await absorbSyncJobIfApplied(db, businessId, row.entity_id, row.command_type, row.id)) {
429
+ clearRetry(businessId);
430
+ return;
431
+ }
432
+ }
433
+ catch { }
434
+ }
435
+ // Bounded retries: blind 4xx retries stop after 5 attempts with a loud
436
+ // toast (attempts only increments on the keep-pending path below).
437
+ if (is4xx && isRetryable4xx && (row.attempts ?? 0) + 1 >= 5) {
438
+ syncLogger.warn("SyncJobs", "Retryable 4xx exhausted — marking job failed", { businessId, entityId, status: rest.status, code: rest.code, attempts: row.attempts });
439
+ try {
440
+ await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""} (after ${MAX_SYNC_JOB_ATTEMPTS} attempts)`.trim(), new Date().toISOString(), row.id);
441
+ }
442
+ catch { }
443
+ clearRetry(businessId);
444
+ try {
445
+ const { notifyError } = await import("../adapters/notify");
446
+ notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
447
+ }
448
+ catch { }
449
+ return;
450
+ }
388
451
  if (isBusinessClosed) {
389
452
  syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
390
453
  try {
@@ -10,6 +10,7 @@ import { syncLogger } from "../utils/sync-logger";
10
10
  import { generateEventId } from "../utils/uuid";
11
11
  import { createLocalOrder, transitionLocalOrder } from "../domain/orders/service";
12
12
  import { isFinalGuardRejection } from "./transition-errors";
13
+ import { getQueuedStatus } from "../utils/order-transitions";
13
14
  import { openSyncDatabase } from "../db/connection";
14
15
  import { getOrderById, updateOrderStatus } from "../db/operations";
15
16
  export class RestTransport {
@@ -143,6 +144,29 @@ export class RestTransport {
143
144
  */
144
145
  async executeOrderTransition(command, action, timestamp, restFallback, eventType = "order.status_changed") {
145
146
  const { business_id, entity_id } = command;
147
+ // Idempotent absorb: a duplicate tap / replayed command whose target
148
+ // status is already applied locally succeeds without touching the
149
+ // guards or the network (this was the "Cannot mark_ready from ready"
150
+ // error storm: the throw also poisoned global reachability before the
151
+ // probe-ownership rule). Genuinely invalid transitions still throw
152
+ // INVALID_TRANSITION from transitionLocalOrder below.
153
+ const target = getQueuedStatus(command.command_type);
154
+ if (target && entity_id) {
155
+ try {
156
+ const db = await openSyncDatabase();
157
+ const local = await getOrderById(db, business_id, entity_id);
158
+ if (local && local.status === target) {
159
+ syncLogger.info("RestTransport", "transition already applied, absorbing", {
160
+ command_type: command.command_type,
161
+ entity_id,
162
+ status: target,
163
+ });
164
+ const data = { ...local, order_id: local.id };
165
+ return this.wrapResponse(eventType, data, local.id, timestamp, command);
166
+ }
167
+ }
168
+ catch { }
169
+ }
146
170
  try {
147
171
  const order = await transitionLocalOrder(business_id, entity_id, action);
148
172
  const data = { ...order, order_id: order.id };
@@ -207,6 +207,17 @@ export function createMemoryDriver() {
207
207
  }
208
208
  return { lastInsertRowId: 0, changes: 1 };
209
209
  }
210
+ // sync_jobs update status=failed with last_error (permanent + exhausted retries)
211
+ if (s.includes("UPDATE sync_jobs SET status='failed'")) {
212
+ const [last_error, updated_at, id] = args;
213
+ const row = tables.sync_jobs.find((r) => r.id === id);
214
+ if (row) {
215
+ row.status = "failed";
216
+ row.last_error = last_error;
217
+ row.updated_at = updated_at;
218
+ }
219
+ return { lastInsertRowId: 0, changes: 1 };
220
+ }
210
221
  if (s.includes("UPDATE sync_jobs SET payload=")) {
211
222
  const [payload, updated_at, id] = args;
212
223
  const row = tables.sync_jobs.find((r) => r.id === id);
@@ -321,6 +332,12 @@ export function createMemoryDriver() {
321
332
  const row = tables.orders.find((r) => r.business_id === business_id && (r.id === id1 || r.order_number === id2));
322
333
  return row ?? null;
323
334
  }
335
+ // orders hlc probe (absorb-if-applied touch detection)
336
+ if (s.includes("SELECT hlc FROM orders WHERE business_id = ? AND id = ?")) {
337
+ const [business_id, id] = args;
338
+ const row = tables.orders.find((r) => r.business_id === business_id && r.id === id);
339
+ return (row ? { hlc: row.hlc } : null);
340
+ }
324
341
  // sync_jobs pending single
325
342
  if (s.includes("SELECT * FROM sync_jobs WHERE business_id = ? AND entity_id = ? AND status='pending'")) {
326
343
  const [business_id, entity_id] = args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
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",