@teincfood/core 0.9.15 → 0.9.16

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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Sync-failure reporter adapter.
3
+ *
4
+ * Core never depends on the observability SDK. The host app registers a
5
+ * reporter at boot (built on `@teincfood/observability`) and core forwards
6
+ * every failed command attempt so operators can see which device/app/user is
7
+ * struggling and go fix it.
8
+ */
9
+ export type SyncFailurePhase = "outbox" | "sync_jobs";
10
+ export interface SyncFailureReport {
11
+ /** Which queue produced the failure. */
12
+ phase: SyncFailurePhase;
13
+ business_id: string;
14
+ user_id?: string;
15
+ /** Stable per-install device id (matches the telemetry device). */
16
+ device_id?: string;
17
+ command_type: string;
18
+ command_id?: string;
19
+ entity_id?: string;
20
+ /** HTTP status when the failure came from a response. */
21
+ status?: number;
22
+ /** Backend/guard error code. */
23
+ code?: string;
24
+ message?: string;
25
+ attempts?: number;
26
+ /** True when the command was dead-lettered (no further auto-retry). */
27
+ terminal: boolean;
28
+ transport?: string;
29
+ }
30
+ export type SyncFailureReporter = (report: SyncFailureReport) => void;
31
+ export declare function setSyncFailureReporter(next: SyncFailureReporter | null): void;
32
+ /** Fire-and-forget; never throws, safe before a reporter is registered. */
33
+ export declare function reportSyncFailure(report: SyncFailureReport): void;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Sync-failure reporter adapter.
3
+ *
4
+ * Core never depends on the observability SDK. The host app registers a
5
+ * reporter at boot (built on `@teincfood/observability`) and core forwards
6
+ * every failed command attempt so operators can see which device/app/user is
7
+ * struggling and go fix it.
8
+ */
9
+ let reporter = null;
10
+ export function setSyncFailureReporter(next) {
11
+ reporter = next;
12
+ }
13
+ /** Fire-and-forget; never throws, safe before a reporter is registered. */
14
+ export function reportSyncFailure(report) {
15
+ if (!reporter)
16
+ return;
17
+ try {
18
+ reporter(report);
19
+ }
20
+ catch {
21
+ /* telemetry must never break sync */
22
+ }
23
+ }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./utils/customer-display";
29
29
  export * from "./utils/order-filter";
30
30
  export { useReferenceData } from "./reference/hooks";
31
31
  export { setNotifyHandlers } from "./adapters/notify";
32
+ export { setSyncFailureReporter, reportSyncFailure, type SyncFailureReport, type SyncFailureReporter, type SyncFailurePhase, } from "./adapters/sync-failure";
32
33
  export { walletService, riderService, fleetService, deliveryService, dashboardService, analyticsService, placesService } from "./adapters/services";
33
34
  export { buildMenuItemEnvelope, buildMenuCategoryEnvelope } from "./reference/menu-record";
34
35
  export { validateMenuItemInput, validateMenuCategoryInput, validateOrderCreateInput, validateInviteInput } from "./validation/offline";
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ export * from "./utils/customer-display";
30
30
  export * from "./utils/order-filter";
31
31
  export { useReferenceData } from "./reference/hooks";
32
32
  export { setNotifyHandlers } from "./adapters/notify";
33
+ export { setSyncFailureReporter, reportSyncFailure, } from "./adapters/sync-failure";
33
34
  export { walletService, riderService, fleetService, deliveryService, dashboardService, analyticsService, placesService } from "./adapters/services";
34
35
  export { buildMenuItemEnvelope, buildMenuCategoryEnvelope } from "./reference/menu-record";
35
36
  export { validateMenuItemInput, validateMenuCategoryInput, validateOrderCreateInput, validateInviteInput } from "./validation/offline";
@@ -24,6 +24,8 @@ 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
+ /** Forward a failed attempt to the host's observability reporter. */
28
+ private reportFailure;
27
29
  /**
28
30
  * Mark a command permanently failed: loud toast + optimistic revert, then
29
31
  * replay continues to the next command. Shared by the exhausted-retry and
@@ -21,7 +21,8 @@ import { generateLocalCalloutNumber, generateOrderNumberForBusiness, generateOrd
21
21
  import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
22
22
  import { canBusinessSync } from "./sync-guard";
23
23
  import { backoffDelayMs } from "./backoff";
24
- const MAX_RETRIES = 5;
24
+ import { reportSyncFailure } from "../adapters/sync-failure";
25
+ const MAX_ATTEMPTS = 8;
25
26
  /**
26
27
  * Stop a replay pass after this many consecutive transport-level failures
27
28
  * (network/timeout/5xx) so a dead server is not hammered once per queued
@@ -30,14 +31,8 @@ const MAX_RETRIES = 5;
30
31
  */
31
32
  const TRANSPORT_FAILURE_BREAKER = 3;
32
33
  /**
33
- * 4xx that deserve another chance: transient races and rate limits, never
34
- * permanent verdicts. Anything else 4xx (400 validation, 401 session,
35
- * 402 plan, 403 forbidden) fails fast with a toast. 409 ORDER_NUMBER_TAKEN
36
- * has its own regen path below and is not capped (every attempt mints a
37
- * new order number, so it is never a blind retry).
34
+ * In-memory next-eligible time per command (shared 2s→5min curve).
38
35
  */
39
- const RETRYABLE_4XX = new Set([404, 408, 409, 422, 429]);
40
- /** In-memory next-eligible time per command (shared 2s→5min curve). */
41
36
  const retryNotBefore = new Map();
42
37
  const QUEUED_STATUS_BY_COMMAND = {
43
38
  "order:accept": "accepted",
@@ -267,7 +262,7 @@ class CommandQueueService {
267
262
  timestamp: command.timestamp,
268
263
  payload: command.payload,
269
264
  };
270
- if (command.retry_count >= MAX_RETRIES) {
265
+ if (command.retry_count >= MAX_ATTEMPTS) {
271
266
  await updateCommandStatus(db, command.command_id, "failed", "Max retry count exceeded");
272
267
  return;
273
268
  }
@@ -336,6 +331,13 @@ class CommandQueueService {
336
331
  catch { }
337
332
  }
338
333
  await updateCommandStatus(db, command.command_id, "failed", `order_number_taken:${message}`);
334
+ this.reportFailure(command, {
335
+ status: 409,
336
+ code: "ORDER_NUMBER_TAKEN",
337
+ message,
338
+ attempts: command.retry_count + 1,
339
+ terminal: true,
340
+ });
339
341
  throw error;
340
342
  }
341
343
  const status = typeof apiErr?.status === "number" ? apiErr.status : undefined;
@@ -352,19 +354,28 @@ class CommandQueueService {
352
354
  }
353
355
  catch { }
354
356
  }
355
- // Retryable 4xx (FIFO races, rate limits): back off on the shared
356
- // 2s→5min curve, then fail loudly after MAX_RETRIES. Throw a
357
- // transport-neutral deferral so replay STOPS here (FIFO ordering) —
358
- // rethrowing the 4xx itself would make the loop `continue` past it.
359
- if (status !== undefined && RETRYABLE_4XX.has(status)) {
357
+ // Every other failure is retryable on the shared 2s→5min backoff.
358
+ //
359
+ // 4xx (validation/permission/plan races) is bounded: a device stuck in
360
+ // a bad local state keeps retrying, then dead-letters so an operator is
361
+ // alerted and can visit the device. 5xx/network is an outage, not a
362
+ // device problem — it keeps retrying indefinitely so a long outage can
363
+ // never lose good orders. Every attempt is reported to observability.
364
+ const is4xxResponse = status !== undefined && status >= 400 && status < 500;
365
+ if (is4xxResponse) {
360
366
  const attempts = command.retry_count + 1;
361
- if (attempts >= MAX_RETRIES) {
362
- await this.failPermanently(db, command, message, `retryable 4xx exhausted after ${attempts} attempts`);
367
+ if (attempts >= MAX_ATTEMPTS) {
368
+ await this.failPermanently(db, command, message, `4xx exhausted after ${attempts} attempts`, {
369
+ status,
370
+ code,
371
+ attempts,
372
+ });
363
373
  return;
364
374
  }
375
+ this.reportFailure(command, { status, code, message, attempts, terminal: false });
365
376
  retryNotBefore.set(command.command_id, Date.now() + backoffDelayMs(command.retry_count));
366
377
  await updateCommandStatus(db, command.command_id, "pending", `retry_4xx:${message}`, true);
367
- syncLogger.warn("CommandQueueService", "Retryable 4xx — backing off", {
378
+ syncLogger.warn("CommandQueueService", "4xx — backing off", {
368
379
  command_id: command.command_id,
369
380
  command_type: command.command_type,
370
381
  status,
@@ -372,30 +383,37 @@ class CommandQueueService {
372
383
  });
373
384
  throw new TransitionError(TRANSITION_ERROR_CODES.REPLAY_DEFERRED, `Retryable ${status} on ${command.command_type}, backing off`);
374
385
  }
375
- const isNonRetryable = status !== undefined &&
376
- status >= 400 &&
377
- status < 500;
378
- if (isNonRetryable) {
379
- await this.failPermanently(db, command, message, "permanent 4xx");
380
- // Do not throw — allow replay to continue to next command.
381
- return;
382
- }
383
- // Network/timeout/5xx: NOT a rejection — keep the command retryable
384
- // indefinitely (status back to pending, retry_count untouched) so a
385
- // long outage never dead-letters the outbox via MAX_RETRIES. Replay
386
- // stops here to preserve ordering; the next trigger retries.
386
+ // 5xx / network / timeout: keep the command retryable indefinitely
387
+ // (status back to pending, retry_count untouched) so a long outage never
388
+ // dead-letters the outbox. Replay defers this entity; the next trigger
389
+ // retries.
390
+ this.reportFailure(command, { status, code, message, attempts: command.retry_count, terminal: false });
387
391
  await updateCommandStatus(db, command.command_id, "pending", `network:${message}`);
388
392
  syncLogger.command(parsedCommand, "queued", { error: message, deferred: true });
389
393
  throw error;
390
394
  }
391
395
  }
396
+ /** Forward a failed attempt to the host's observability reporter. */
397
+ reportFailure(command, extra) {
398
+ reportSyncFailure({
399
+ phase: "outbox",
400
+ business_id: command.business_id,
401
+ user_id: command.user_id,
402
+ device_id: command.device_id,
403
+ command_type: command.command_type,
404
+ command_id: command.command_id,
405
+ entity_id: command.entity_id,
406
+ ...extra,
407
+ });
408
+ }
392
409
  /**
393
410
  * Mark a command permanently failed: loud toast + optimistic revert, then
394
411
  * replay continues to the next command. Shared by the exhausted-retry and
395
412
  * permanent-rejection paths.
396
413
  */
397
- async failPermanently(db, command, message, reason) {
414
+ async failPermanently(db, command, message, reason, extra) {
398
415
  await updateCommandStatus(db, command.command_id, "failed", `permanent:${message}`);
416
+ this.reportFailure(command, { ...extra, message, terminal: true });
399
417
  syncLogger.warn("CommandQueueService", `Queued command failed (${reason})`, {
400
418
  command_id: command.command_id,
401
419
  command_type: command.command_type,
@@ -19,11 +19,13 @@ import { getTransitionPath, DELIVERY_COMMANDS } 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";
22
+ import { reportSyncFailure } from "../adapters/sync-failure";
23
+ import { getAppInstanceId } from "../adapters/devices";
22
24
  const inflight = new Map();
23
25
  const retryTimers = new Map();
24
26
  const backoffTracker = new BackoffTracker();
25
- /** Blind 4xx retries stop here, loudly. 5xx/network retry indefinitely. */
26
- const MAX_SYNC_JOB_ATTEMPTS = 5;
27
+ /** Bounded 4xx retries stop here, loudly. 5xx/network retry indefinitely. */
28
+ const MAX_SYNC_JOB_ATTEMPTS = 8;
27
29
  function isOnline() {
28
30
  const s = getConnectivityState();
29
31
  return s.isOnline && s.isCloudReachable !== "unreachable";
@@ -65,6 +67,36 @@ function clearRetry(businessId) {
65
67
  }
66
68
  backoffTracker.reset(businessId);
67
69
  }
70
+ /** Best-effort device id for failure reports (never throws). */
71
+ function safeDeviceId() {
72
+ try {
73
+ return getAppInstanceId();
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ /** Forward a failed job attempt to the host's observability reporter. */
80
+ function reportJobFailure(businessId, row, extra) {
81
+ let userId;
82
+ try {
83
+ const p = JSON.parse(row.payload);
84
+ const candidate = p.user_id ?? p.user?.id;
85
+ if (typeof candidate === "string")
86
+ userId = candidate;
87
+ }
88
+ catch { }
89
+ reportSyncFailure({
90
+ phase: "sync_jobs",
91
+ business_id: businessId,
92
+ user_id: userId,
93
+ device_id: safeDeviceId(),
94
+ command_type: row.command_type,
95
+ command_id: row.id,
96
+ entity_id: row.entity_id,
97
+ ...extra,
98
+ });
99
+ }
68
100
  async function tryPhxPush(businessId, payload) {
69
101
  try {
70
102
  const socket = phoenixSocket.getChannel?.(`business:${businessId}`);
@@ -385,38 +417,13 @@ export async function drainOne(businessId, entityId) {
385
417
  syncLogger.debug("SyncJobs", "401 deferred until login", { businessId });
386
418
  return;
387
419
  }
388
- // Permanent 4xx: mark failed to stop the retry loop.
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
420
+ // Any 4xx is retryable with backoff (bounded); 5xx/network keeps retrying
421
+ // indefinitely. Every failed attempt is reported to observability so an
422
+ // operator can see the device/app/user and visit it. BUSINESS_CLOSED is a
423
+ // legitimate closed state (keep pending, re-pull hours). Backend codes are
393
424
  // exact (no message matching).
394
425
  const isBusinessClosed = rest.code === "BUSINESS_CLOSED" || rest.code === "HOURS_NOT_SYNCED";
395
- const is4xx = rest.status && rest.status >= 400 && rest.status < 500;
396
- const isRetryable4xx = rest.status === 401 || rest.status === 408 || rest.status === 409
397
- || rest.status === 422 || rest.status === 429 || rest.status === 404
398
- || isBusinessClosed;
399
- if (is4xx && !isRetryable4xx) {
400
- syncLogger.warn("SyncJobs", "Permanent 4xx — marking job failed to stop retry loop", { businessId, entityId, status: rest.status, code: rest.code, message: rest.message });
401
- try {
402
- await db.runAsync(`UPDATE sync_jobs SET status='failed', last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
403
- }
404
- catch { }
405
- clearRetry(businessId);
406
- try {
407
- const { notifyError } = await import("../adapters/notify");
408
- notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status}`);
409
- }
410
- catch { }
411
- // Retain optimistic row — pull will converge for transitions, create stays for manual retry.
412
- if (row.command_type !== "order:create") {
413
- try {
414
- void pullRemoteOrders(businessId).catch(() => { });
415
- }
416
- catch { }
417
- }
418
- return;
419
- }
426
+ const is4xx = !!rest.status && rest.status >= 400 && rest.status < 500;
420
427
  // Already-applied absorb for transitions: the server already moved the
421
428
  // order (duplicate tap, earlier drain won the race) — complete the job
422
429
  // instead of failing it. Server truth only (hlc-guarded pull), never the
@@ -432,39 +439,43 @@ export async function drainOne(businessId, entityId) {
432
439
  }
433
440
  catch { }
434
441
  }
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 });
442
+ if (isBusinessClosed) {
443
+ syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
444
+ reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts: row.attempts, terminal: false });
439
445
  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);
446
+ await db.runAsync(`UPDATE sync_jobs SET last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
441
447
  }
442
448
  catch { }
443
- clearRetry(businessId);
449
+ // Re-pull reference so hours merge converges, then retry with backoff.
444
450
  try {
445
- const { notifyError } = await import("../adapters/notify");
446
- notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
451
+ const { referenceSyncService } = await import("../reference/service");
452
+ void referenceSyncService.sync({ businessId, keys: ["operating-hours", "business:profile"] }).catch(() => { });
447
453
  }
448
454
  catch { }
455
+ scheduleRetry(businessId);
449
456
  return;
450
457
  }
451
- if (isBusinessClosed) {
452
- syncLogger.warn("SyncJobs", "Business closed — keeping pending for retry after hours sync", { businessId, entityId, code: rest.code });
458
+ const attempts = (row.attempts ?? 0) + 1;
459
+ // Bounded 4xx: after MAX_SYNC_JOB_ATTEMPTS, dead-letter (retained) with a
460
+ // loud toast + observability report so an operator can visit the device.
461
+ if (is4xx && attempts >= MAX_SYNC_JOB_ATTEMPTS) {
462
+ syncLogger.warn("SyncJobs", "4xx exhausted — marking job failed", { businessId, entityId, status: rest.status, code: rest.code, attempts: row.attempts });
453
463
  try {
454
- await db.runAsync(`UPDATE sync_jobs SET last_error=?, updated_at=? WHERE id=?;`, `${rest.status} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
464
+ 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);
455
465
  }
456
466
  catch { }
457
- // Re-pull reference so hours merge converges, then retry with backoff.
467
+ reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: true });
468
+ clearRetry(businessId);
458
469
  try {
459
- const { referenceSyncService } = await import("../reference/service");
460
- void referenceSyncService.sync({ businessId, keys: ["operating-hours", "business:profile"] }).catch(() => { });
470
+ const { notifyError } = await import("../adapters/notify");
471
+ notifyError(`Server rejected ${row.command_type}: ${rest.message ?? rest.code ?? rest.status} (after retries)`);
461
472
  }
462
473
  catch { }
463
- scheduleRetry(businessId);
464
474
  return;
465
475
  }
466
- // Keep pending for retry (5xx / network) — auto retry with backoff
476
+ // 4xx (bounded) or 5xx/network (indefinite): keep pending and back off.
467
477
  syncLogger.debug("SyncJobs", "drainOne kept pending for retry", { businessId, entityId, status: rest.status, code: rest.code });
478
+ reportJobFailure(businessId, row, { status: rest.status, code: rest.code, message: rest.message, attempts, terminal: false });
468
479
  try {
469
480
  await db.runAsync(`UPDATE sync_jobs SET attempts = attempts + 1, last_error = ?, updated_at = ? WHERE id = ?;`, `${rest.status ?? "network"} ${rest.code ?? ""} ${rest.message ?? ""}`.trim(), new Date().toISOString(), row.id);
470
481
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.9.15",
3
+ "version": "0.9.16",
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",