@tallyui/pos 2.0.0 → 3.0.0-next.0
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/index.d.ts +321 -61
- package/dist/index.js +1391 -185
- package/package.json +8 -8
- package/src/index.ts +15 -7
- package/src/order/index.ts +3 -0
- package/src/order/order-builder.ts +23 -7
- package/src/order/order-manager.ts +3 -1
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +9 -3
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +5 -4
- package/src/outbox/index.ts +2 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +331 -27
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +17 -3
- package/src/outbox/use-order-outbox.ts +20 -6
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +99 -11
- package/src/pos-order/finalize.ts +145 -7
- package/src/pos-order/index.ts +2 -2
- package/src/pos-order/needs-attention.ts +9 -4
- package/src/pos-order/open.ts +100 -37
- package/src/pos-order/schema.ts +35 -5
- package/src/pos-order/types.ts +31 -5
- package/src/product/search-products.ts +5 -2
- package/src/receipt/build-receipt-data.ts +5 -3
- package/src/receipt/types.ts +1 -0
- package/src/register/closure-document.ts +7 -0
- package/src/register/index.ts +5 -3
- package/src/register/movement-input.ts +1 -1
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-document.ts +26 -1
- package/src/register/session-store.ts +41 -12
- package/src/register/use-register-session.ts +46 -2
- package/src/sale/cart.ts +1 -0
- package/src/sale/use-sale.ts +97 -40
- package/src/store-settings/map-store-settings.ts +7 -2
- package/src/store-settings/use-store-settings.ts +22 -7
- package/src/tax/exact.ts +74 -11
- package/src/tax/index.ts +1 -1
- package/src/tax/tax-provider.tsx +26 -4
- package/src/tax/types.ts +6 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OrderCreateEnvelope } from '@tallyui/core';
|
|
2
|
+
import { deepEqual, type RxCollection, type RxDocumentData } from 'rxdb';
|
|
2
3
|
import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
|
|
3
|
-
import {
|
|
4
|
+
import { outboxLogger } from './logger';
|
|
5
|
+
import { toOrderCreateEnvelope, UnsupportedOrderVersionError, uuidv7, type PosOrder, type PosOrderServerFailures } from '../pos-order';
|
|
6
|
+
import { freezeSentForm } from '../pos-order/finalize';
|
|
4
7
|
import { countFresh, readFresh } from '../rxdb';
|
|
8
|
+
import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
|
|
5
9
|
import type { CommandTransport, OutboxState } from './types';
|
|
6
10
|
|
|
7
11
|
// Pause after three 401s since the server last accepted credentials.
|
|
@@ -11,15 +15,38 @@ const AUTH_FAILURES_BEFORE_PROMPT = 3;
|
|
|
11
15
|
// command id could duplicate it, so requeue() leaves these for manual reconciliation.
|
|
12
16
|
const NOT_REQUEUEABLE = new Set(['idempotency_mismatch']);
|
|
13
17
|
|
|
18
|
+
// The store's message in the refusal log is cut to this many characters, the cap connectors use for a foreign 426 message.
|
|
19
|
+
const REFUSAL_LOG_MESSAGE_MAX = 200;
|
|
20
|
+
|
|
21
|
+
// After this many consecutive failures the store answered (not offline) with the same head order and
|
|
22
|
+
// no progress, the queue is probed one order at a time, so an order the store can't take holds up only
|
|
23
|
+
// itself. With the default backoff the first probe goes after about 30 s: sales keep flowing within minutes.
|
|
24
|
+
export const ISOLATE_AFTER_ATTEMPTS = 5;
|
|
25
|
+
|
|
26
|
+
// An order the store has kept failing (server-answered) for this much answered time, offline gaps left out,
|
|
27
|
+
// is flagged in OutboxState.stuck. It stays pending and keeps retrying.
|
|
28
|
+
export const STUCK_AFTER_MS = 15 * 60_000;
|
|
29
|
+
|
|
30
|
+
// An advertised order.create max the outbox can use: a positive safe integer, else unknown.
|
|
31
|
+
const validMax = (max: unknown) => (typeof max === 'number' && Number.isSafeInteger(max) && max > 0 ? max : undefined);
|
|
32
|
+
|
|
14
33
|
export interface OrderOutboxOptions {
|
|
15
34
|
collection: RxCollection<PosOrder>;
|
|
16
|
-
transport: CommandTransport
|
|
35
|
+
transport: CommandTransport<OrderCreateEnvelope>;
|
|
17
36
|
deviceId: string;
|
|
37
|
+
getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
|
|
38
|
+
refreshCapabilities?: () => Promise<void>;
|
|
18
39
|
batchSize?: number;
|
|
19
40
|
initialBackoffMs?: number;
|
|
20
41
|
maxBackoffMs?: number;
|
|
21
42
|
random?: () => number;
|
|
22
43
|
now?: () => number;
|
|
44
|
+
/** Tests only: overrides ISOLATE_AFTER_ATTEMPTS. */
|
|
45
|
+
isolateAfterAttempts?: number;
|
|
46
|
+
/** Tests only: overrides STUCK_AFTER_MS. */
|
|
47
|
+
stuckAfterMs?: number;
|
|
48
|
+
/** Counts 404s toward OutboxState.backendMissing; pass the register outbox's too, so either one's 404s show one notice. */
|
|
49
|
+
backendNotFound?: BackendNotFound;
|
|
23
50
|
}
|
|
24
51
|
|
|
25
52
|
export interface OrderOutbox {
|
|
@@ -43,6 +70,12 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
43
70
|
const random = options.random ?? Math.random;
|
|
44
71
|
const now = options.now ?? Date.now;
|
|
45
72
|
const state$ = new BehaviorSubject<OutboxState>({ pending: 0, sending: false });
|
|
73
|
+
const backendNotFound = options.backendNotFound ?? createBackendNotFound();
|
|
74
|
+
let missingSubscription: Subscription | undefined; // stop() lets go of the (maybe shared) tracker; start() and flush() take it up again
|
|
75
|
+
const watchBackendMissing = () => missingSubscription ??= backendNotFound.backendMissing$.subscribe((backendMissing) => {
|
|
76
|
+
if (backendMissing !== state$.value.backendMissing) state$.next({ ...state$.value, backendMissing });
|
|
77
|
+
});
|
|
78
|
+
watchBackendMissing();
|
|
46
79
|
const attempts = new Map<string, number>();
|
|
47
80
|
let backoff = initialBackoff;
|
|
48
81
|
let unauthorizedSinceAccepted = 0;
|
|
@@ -52,40 +85,225 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
52
85
|
let stopped = false;
|
|
53
86
|
let insertedDuringRun = false;
|
|
54
87
|
let stoppedDuringRun = false;
|
|
88
|
+
const isolateAfter = options.isolateAfterAttempts ?? ISOLATE_AFTER_ATTEMPTS;
|
|
89
|
+
const stuckAfter = options.stuckAfterMs ?? STUCK_AFTER_MS;
|
|
90
|
+
// Each order's stuck clock and isolation are mirrored onto its stored `serverFailures` and restored when an outbox
|
|
91
|
+
// starts. In memory only, so a restart starts them again: `head`, its failure count, and an unfinished walk (whose
|
|
92
|
+
// failed probes are stored as isolated). `head` counts server-answered failures of batches
|
|
93
|
+
// led by one order. At `isolateAfter` a walk starts: one probe (an order sent alone) per backoff interval,
|
|
94
|
+
// through the whole pending queue, oldest first, each order once. When the store takes a probe it is up:
|
|
95
|
+
// the orders whose probes failed are isolated and batching resumes. When no order is left to probe, the
|
|
96
|
+
// store is down: nothing is isolated, and batching resumes with its backoff.
|
|
97
|
+
let head: { id: string; count: number } | undefined;
|
|
98
|
+
let walk: { failed: Map<string, string> } | undefined;
|
|
99
|
+
// Isolated orders are left out of batches and retried alone, each on its own backoff, while pending
|
|
100
|
+
// under their commandId.
|
|
101
|
+
const isolated = new Map<string, { backoff: number; nextAt: number; reason: string }>();
|
|
102
|
+
// Each order's stuck clock counts only answered time. It starts at the order's first server-answered failure (in
|
|
103
|
+
// a batch, as a probe or alone; `timeout` counts, like a 503); an offline (`network`) failure pauses every running
|
|
104
|
+
// clock (`pausedAt`), and the next response the store answers, of any kind and for any order, resumes them all,
|
|
105
|
+
// moving `since` on by the offline gap: a pause is about the connection. So `since` is when the clock would have
|
|
106
|
+
// started had there been no offline gaps: now minus its answered time (while paused, as of the pause). Progress,
|
|
107
|
+
// or the order leaving pending, clears it.
|
|
108
|
+
const clocks = new Map<string, { since: number; pausedAt?: number; seq: number; reason: string }>();
|
|
109
|
+
let failureSeq = 0; // numbers failures, so `stuck` can name the latest reason
|
|
110
|
+
// Timer runs alternate between the batch turn (the batch, or the walk's probe) and the isolated turn (one due
|
|
111
|
+
// isolated order); a turn with nothing to send gives way to the other, so neither side can starve the other.
|
|
112
|
+
let turn: 'batch' | 'isolated' = 'batch';
|
|
113
|
+
let isolatedWait = false;
|
|
114
|
+
let runProgressed = false;
|
|
115
|
+
let timerFiredDuringRun = false;
|
|
116
|
+
let restored = false;
|
|
117
|
+
|
|
118
|
+
function stuckState(): OutboxState['stuck'] {
|
|
119
|
+
const stuck = [...clocks].filter(([, clock]) => (clock.pausedAt ?? now()) - clock.since >= stuckAfter);
|
|
120
|
+
if (!stuck.length) return undefined;
|
|
121
|
+
return { commandIds: stuck.map(([id]) => id), since: Math.min(...stuck.map(([, clock]) => clock.since)),
|
|
122
|
+
reason: stuck.reduce((latest, entry) => (entry[1].seq > latest[1].seq ? entry : latest))[1].reason,
|
|
123
|
+
orders: stuck.map(([commandId, { since, reason }]) => ({ commandId, since, reason })) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// An isolated order is due once its backoff has passed, or when the clock was set back further than any backoff.
|
|
127
|
+
function isDue(id: string) {
|
|
128
|
+
const entry = isolated.get(id);
|
|
129
|
+
return !!entry && (entry.nextAt <= now() || entry.nextAt - now() > maxBackoff);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// An order's stored serverFailures: its clock, and whether it failed alone (isolated, or a failed probe).
|
|
133
|
+
function failuresOf(commandId: string): PosOrderServerFailures | undefined {
|
|
134
|
+
const clock = clocks.get(commandId);
|
|
135
|
+
return clock && { since: clock.since, reason: clock.reason.slice(0, 64),
|
|
136
|
+
isolated: isolated.has(commandId) || !!walk?.failed.has(commandId) };
|
|
137
|
+
}
|
|
55
138
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
139
|
+
// Writes each clocked pending order's serverFailures when the stored value would change. The writes that clear a
|
|
140
|
+
// clock (applied, rejected, downgraded, requeued) remove the field, so every stored field has a clock here.
|
|
141
|
+
async function mirror() {
|
|
142
|
+
if (!clocks.size) return;
|
|
143
|
+
for (const order of await readFresh(collection, { selector: { syncStatus: 'pending', commandId: { $in: [...clocks.keys()] } } })) {
|
|
144
|
+
const value = failuresOf(order.commandId);
|
|
145
|
+
if (!value || deepEqual(value, order.serverFailures)) continue;
|
|
146
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
|
|
147
|
+
if (data.syncStatus === 'pending' && data.commandId === order.commandId) data.serverFailures = value;
|
|
148
|
+
return data;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Before the first send: each pending order's stored clock, paused until the store next answers (so the time the
|
|
154
|
+
// till was off counts as answered: never a later flag than before), and its isolation, due at once.
|
|
155
|
+
async function restore() {
|
|
156
|
+
for (const { commandId, serverFailures } of await readFresh(collection, { selector: { syncStatus: 'pending',
|
|
157
|
+
serverFailures: { $exists: true } }, sort: [{ createdAt: 'asc' }] })) if (serverFailures) {
|
|
158
|
+
const { since, reason } = serverFailures;
|
|
159
|
+
clocks.set(commandId, { since, pausedAt: now(), seq: ++failureSeq, reason });
|
|
160
|
+
if (serverFailures.isolated) isolated.set(commandId, { backoff: initialBackoff, nextAt: now(), reason });
|
|
161
|
+
}
|
|
162
|
+
restored = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// An update that changed only serverFailures is the outbox's own mirror write: nothing new to send.
|
|
166
|
+
const bare = ({ serverFailures, _rev, _meta, ...data }: RxDocumentData<PosOrder>) => data;
|
|
167
|
+
const failuresOnly = (before: RxDocumentData<PosOrder>, after: RxDocumentData<PosOrder>) =>
|
|
168
|
+
!deepEqual(before.serverFailures, after.serverFailures) && deepEqual(bare(before), bare(after));
|
|
169
|
+
|
|
170
|
+
// Every read bypasses the query cache. RxDB 17 fixed RxDB 16.21.1's bug 4 (rxdb#7067);
|
|
171
|
+
// readFresh, countFresh and watchFresh remain correct public API.
|
|
172
|
+
// Retiring them is a separate decision.
|
|
58
173
|
async function updateState(patch: Partial<OutboxState> = {}) {
|
|
59
174
|
const pending = await countFresh(collection, { syncStatus: 'pending' });
|
|
60
|
-
|
|
175
|
+
const rejected = await countFresh(collection, { syncStatus: 'rejected' });
|
|
176
|
+
state$.next({ ...state$.value, ...patch, pending, rejected, stuck: stuckState() });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isolate(id: string, reason: string, retryAfterMs = 0) {
|
|
180
|
+
const entry = isolated.get(id) ?? { backoff: initialBackoff };
|
|
181
|
+
const delay = Math.min(Math.max(retryAfterMs, entry.backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
182
|
+
isolated.set(id, { reason, backoff: Math.min(entry.backoff * 2, maxBackoff), nextAt: now() + delay });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A failure the store answered (not offline). Returns undefined when the run goes on at once, or the
|
|
186
|
+
// floor for the retry: the store's Retry-After, or an isolated order's own backoff.
|
|
187
|
+
function serverFailed(orders: PosOrder[], alone: 'probe' | 'isolated' | undefined, reason: string, retryAfterMs?: number) {
|
|
188
|
+
const at = now();
|
|
189
|
+
for (const { commandId } of orders) clocks.set(commandId, { since: clocks.get(commandId)?.since ?? at, seq: ++failureSeq, reason });
|
|
190
|
+
const id = orders[0].commandId;
|
|
191
|
+
if (alone === 'isolated') {
|
|
192
|
+
isolate(id, reason, retryAfterMs);
|
|
193
|
+
// Once the store took something this run it is up, and the run goes on, unless it asked to wait
|
|
194
|
+
// (Retry-After, 429). Otherwise it may be down: one request per backoff interval.
|
|
195
|
+
if (runProgressed && retryAfterMs === undefined && reason !== 'status_429') return undefined;
|
|
196
|
+
return Math.max(retryAfterMs ?? 0, isolated.get(id)!.nextAt - now());
|
|
197
|
+
}
|
|
198
|
+
if (alone === 'probe') walk?.failed.set(id, reason);
|
|
199
|
+
else {
|
|
200
|
+
head = { id, count: head?.id === id ? head.count + 1 : 1 };
|
|
201
|
+
if (head.count >= isolateAfter) walk ??= { failed: new Map() };
|
|
202
|
+
}
|
|
203
|
+
return retryAfterMs ?? 0;
|
|
61
204
|
}
|
|
62
205
|
|
|
63
|
-
|
|
206
|
+
// `wait` arms the timer for an isolated order's own backoff, leaving the outbox's backoff alone.
|
|
207
|
+
function scheduleRetry(reason: string, retryAfterMs = 0, wait?: number) {
|
|
64
208
|
state$.next({ ...state$.value, lastRetryReason: reason });
|
|
65
209
|
if (stopped) { stoppedDuringRun = true; return; }
|
|
66
|
-
const delay = Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
67
|
-
backoff = Math.min(backoff * 2, maxBackoff);
|
|
68
|
-
|
|
210
|
+
const delay = wait ?? Math.min(Math.max(retryAfterMs, backoff * (0.9 + 0.2 * random())), maxBackoff);
|
|
211
|
+
if (wait === undefined) backoff = Math.min(backoff * 2, maxBackoff);
|
|
212
|
+
isolatedWait = wait !== undefined;
|
|
213
|
+
// A timer firing while a run is still finishing finds it busy: the run's end flushes again.
|
|
214
|
+
timer = setTimeout(() => { timer = undefined; if (running) timerFiredDuringRun = true; flush().catch(() => {}); }, delay);
|
|
69
215
|
state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
|
|
70
216
|
}
|
|
71
217
|
|
|
72
218
|
async function run() {
|
|
73
219
|
stoppedDuringRun = false;
|
|
220
|
+
runProgressed = false;
|
|
221
|
+
const sentAlone = new Set<string>();
|
|
74
222
|
while (!stopped) try {
|
|
223
|
+
if (!restored) await restore();
|
|
75
224
|
await updateState({ sending: true, nextAttemptAt: undefined });
|
|
76
225
|
insertedDuringRun = false;
|
|
77
|
-
|
|
78
|
-
|
|
226
|
+
// An order no longer pending under its commandId leaves the isolated set and loses its stuck clock.
|
|
227
|
+
const tracked = [...new Set([...isolated.keys(), ...clocks.keys()])];
|
|
228
|
+
const held = tracked.length ? await readFresh(collection, { selector: { syncStatus: 'pending', commandId: { $in: tracked } },
|
|
229
|
+
sort: [{ createdAt: 'asc' }] }) : [];
|
|
230
|
+
for (const id of tracked) if (!held.some((order) => order.commandId === id)) { isolated.delete(id); clocks.delete(id); }
|
|
231
|
+
// During a walk, the oldest pending order neither probed nor isolated, alone. A sale rung up during
|
|
232
|
+
// a walk triggers its next probe: still one request per sale. None left: the store is down.
|
|
233
|
+
let probe: PosOrder | undefined;
|
|
234
|
+
if (walk) {
|
|
235
|
+
[probe] = await readFresh(collection, { selector: { syncStatus: 'pending',
|
|
236
|
+
commandId: { $nin: [...isolated.keys(), ...walk.failed.keys()] } }, sort: [{ createdAt: 'asc' }], limit: 1 });
|
|
237
|
+
if (!probe) { walk = undefined; head = undefined; }
|
|
238
|
+
}
|
|
239
|
+
// The isolated turn: an isolated order whose backoff is due, alone (once per run), the one due longest, so
|
|
240
|
+
// isolated orders take turns too. The batch turn: the probe, else a batch without the isolated orders.
|
|
241
|
+
// Whichever turn is next goes, unless it has nothing.
|
|
242
|
+
const [due] = held.filter((order) => isDue(order.commandId) && !sentAlone.has(order.commandId))
|
|
243
|
+
.sort((a, b) => isolated.get(a.commandId)!.nextAt - isolated.get(b.commandId)!.nextAt);
|
|
244
|
+
let orders = turn === 'isolated' && due ? [due] : probe ? [probe] : await readFresh(collection, {
|
|
245
|
+
selector: { syncStatus: 'pending', ...(isolated.size ? { commandId: { $nin: [...isolated.keys()] } } : {}) },
|
|
246
|
+
sort: [{ createdAt: 'asc' }], limit: batchSize,
|
|
79
247
|
});
|
|
80
|
-
if (!orders.length
|
|
81
|
-
|
|
248
|
+
if (!orders.length && due) orders = [due];
|
|
249
|
+
if (!orders.length || stopped) {
|
|
250
|
+
if (stopped || !isolated.size) { stoppedDuringRun = stopped; return; }
|
|
251
|
+
if (insertedDuringRun) continue;
|
|
252
|
+
const next = [...isolated.values()].sort((a, b) => a.nextAt - b.nextAt)[0];
|
|
253
|
+
return scheduleRetry(next.reason, 0, Math.min(maxBackoff, Math.max(initialBackoff, next.nextAt - now())));
|
|
254
|
+
}
|
|
255
|
+
const alone = due && orders[0] === due ? 'isolated' : probe ? 'probe' : undefined;
|
|
256
|
+
if (alone === 'isolated') sentAlone.add(orders[0].commandId);
|
|
257
|
+
turn = alone === 'isolated' ? 'batch' : 'isolated';
|
|
258
|
+
// An order goes out, on every attempt, at the version it first went out at: a retry's bytes never change, even
|
|
259
|
+
// after the store upgrades (#286). A first send takes the server's max, read once per batch; unknown is 3.
|
|
260
|
+
const firstSend = orders.some((order) => order.sentVersion === undefined);
|
|
261
|
+
const serverMax = (firstSend && validMax(await Promise.resolve().then(() => options.getMaxOrderCreateVersion?.())
|
|
262
|
+
.catch((cause) => { outboxLogger.warn('Failed to read capabilities', { cause }); }))) || 3;
|
|
263
|
+
const batch = await Promise.all(orders.map(async (order) => {
|
|
264
|
+
const frozen = freezeSentForm(order);
|
|
82
265
|
const attempt = (attempts.get(order.commandId) ?? 0) + 1;
|
|
83
266
|
attempts.set(order.commandId, attempt);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
267
|
+
let envelope: OrderCreateEnvelope;
|
|
268
|
+
try {
|
|
269
|
+
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: order.sentVersion ?? serverMax });
|
|
270
|
+
} catch (cause) {
|
|
271
|
+
// A discounted order the server can take only at 1 goes as before (at most 3): the refusal rejects it.
|
|
272
|
+
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
273
|
+
envelope = toOrderCreateEnvelope(frozen, deviceId, attempt, { maxVersion: 3 });
|
|
274
|
+
}
|
|
275
|
+
// Bounds apply when frozen: persist older tills' sent forms before sending (Front desk, 2026-09-29). A first
|
|
276
|
+
// send's version is stored, awaited, before the batch request leaves, so a crash in between keeps it.
|
|
277
|
+
if (frozen !== order || order.sentVersion === undefined) {
|
|
278
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) =>
|
|
279
|
+
({ ...freezeSentForm(data), sentVersion: data.sentVersion ?? envelope.version }));
|
|
280
|
+
}
|
|
281
|
+
return envelope;
|
|
282
|
+
}));
|
|
283
|
+
let outcome = await transport.send(batch);
|
|
284
|
+
// A 404 answers the whole batch (the route is missing); any other answer resets the count. Offline changes nothing.
|
|
285
|
+
backendNotFound.record(outcome, now());
|
|
286
|
+
if (outcome.kind !== 'retry' || outcome.reason !== 'network') {
|
|
287
|
+
// The store answered: every paused clock resumes, leaving out the offline gap. A device clock set back
|
|
288
|
+
// during it makes the gap negative, which keeps the answered time exact, since `now` moved back too.
|
|
289
|
+
const at = now();
|
|
290
|
+
for (const clock of clocks.values()) if (clock.pausedAt !== undefined) {
|
|
291
|
+
clock.since += at - clock.pausedAt;
|
|
292
|
+
clock.pausedAt = undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
let batchMax: number | undefined;
|
|
296
|
+
let capabilitiesRefreshed = false;
|
|
87
297
|
if (outcome.kind === 'retry') {
|
|
88
|
-
|
|
298
|
+
let floor = outcome.retryAfterMs;
|
|
299
|
+
if (outcome.reason === 'network') {
|
|
300
|
+
// Offline never counts: it pauses every stuck clock and ends a walk, isolating nothing. The
|
|
301
|
+
// failure count stands, so the next failure the store answers walks again. A `timeout` (sent, but
|
|
302
|
+
// no answer in time) is not offline: it counts like a 503 and never pauses a clock.
|
|
303
|
+
for (const clock of clocks.values()) clock.pausedAt ??= now();
|
|
304
|
+
walk = undefined;
|
|
305
|
+
} else if ((floor = serverFailed(orders, alone, outcome.reason, outcome.retryAfterMs)) === undefined) continue;
|
|
306
|
+
return scheduleRetry(outcome.reason, floor);
|
|
89
307
|
}
|
|
90
308
|
if (outcome.kind === 'unauthorized') {
|
|
91
309
|
unauthorizedSinceAccepted++;
|
|
@@ -94,6 +312,23 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
94
312
|
sending: false, nextAttemptAt: undefined });
|
|
95
313
|
return;
|
|
96
314
|
}
|
|
315
|
+
if (outcome.kind === 'refused' && outcome.status === 400 && options.getMaxOrderCreateVersion) {
|
|
316
|
+
const match = /^Invalid commands\[(\d+)\]\.version$/.exec(outcome.reason);
|
|
317
|
+
if (match) {
|
|
318
|
+
try { await options.refreshCapabilities?.(); } catch (cause) { outboxLogger.warn('Failed to refresh capabilities', { cause }); }
|
|
319
|
+
const advertised = await options.getMaxOrderCreateVersion();
|
|
320
|
+
const max = typeof advertised === 'number' && Number.isSafeInteger(advertised) && advertised > 0 ? advertised : undefined;
|
|
321
|
+
if (max !== undefined && max < batch[Number(match[1])]?.version) {
|
|
322
|
+
batchMax = max;
|
|
323
|
+
capabilitiesRefreshed = true;
|
|
324
|
+
const message = outcome.reason;
|
|
325
|
+
// Validation refused the whole batch before processing: only orders above the cap need changes.
|
|
326
|
+
outcome = { kind: 'results', results: batch.filter((command) => command.version > max).map((command) => ({
|
|
327
|
+
id: command.id, status: 'rejected', error: { code: 'unsupported_version', message, data: { orderCreate: max } },
|
|
328
|
+
})) };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
97
332
|
if (outcome.kind === 'refused') {
|
|
98
333
|
unauthorizedSinceAccepted = 0;
|
|
99
334
|
state$.next({ ...state$.value, refused: { status: outcome.status, reason: outcome.reason },
|
|
@@ -103,6 +338,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
103
338
|
unauthorizedSinceAccepted = 0;
|
|
104
339
|
state$.next({ ...state$.value, authRequired: false, refused: undefined });
|
|
105
340
|
let progressed = false;
|
|
341
|
+
let downgraded = false;
|
|
106
342
|
for (const order of orders) {
|
|
107
343
|
const result = outcome.results.find((entry) => entry.id === order.commandId);
|
|
108
344
|
if (!result) continue;
|
|
@@ -113,18 +349,72 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
113
349
|
if (stored?.syncStatus !== 'pending' || stored.commandId !== order.commandId) continue;
|
|
114
350
|
const current = await collection.findOne(order.id).exec();
|
|
115
351
|
if (!current) continue;
|
|
352
|
+
let error = result.error;
|
|
353
|
+
if (result.status === 'rejected' && error?.code === 'unsupported_version' && options.getMaxOrderCreateVersion) {
|
|
354
|
+
if (!capabilitiesRefreshed) {
|
|
355
|
+
try { await options.refreshCapabilities?.(); } catch (cause) { outboxLogger.warn('Failed to refresh capabilities', { cause }); }
|
|
356
|
+
const advertised = error.data?.orderCreate;
|
|
357
|
+
const max = typeof advertised === 'number' && Number.isSafeInteger(advertised) && advertised > 0
|
|
358
|
+
? advertised : await options.getMaxOrderCreateVersion();
|
|
359
|
+
batchMax = typeof max === 'number' && Number.isSafeInteger(max) && max > 0 ? max : undefined;
|
|
360
|
+
capabilitiesRefreshed = true;
|
|
361
|
+
}
|
|
362
|
+
const max = batchMax;
|
|
363
|
+
const from = batch.find((command) => command.id === order.commandId)!.version;
|
|
364
|
+
if (max !== undefined && max < (stored.sentVersion ?? from)) {
|
|
365
|
+
try {
|
|
366
|
+
const to = toOrderCreateEnvelope(stored, deviceId, 1, { maxVersion: max }).version;
|
|
367
|
+
let changed = false;
|
|
368
|
+
await current.incrementalModify((data) => {
|
|
369
|
+
changed = data.syncStatus === 'pending' && data.commandId === order.commandId && max < (data.sentVersion ?? from);
|
|
370
|
+
if (changed) { data.sentVersion = to; data.downgradedFrom ??= from; delete data.serverFailures; }
|
|
371
|
+
return data;
|
|
372
|
+
});
|
|
373
|
+
if (changed) {
|
|
374
|
+
outboxLogger.warn('Sent an order at a lower order.create version', { orderId: order.id, from, to });
|
|
375
|
+
progressed = downgraded = true;
|
|
376
|
+
clocks.delete(order.commandId);
|
|
377
|
+
}
|
|
378
|
+
continue;
|
|
379
|
+
} catch (cause) {
|
|
380
|
+
if (!(cause instanceof UnsupportedOrderVersionError)) throw cause;
|
|
381
|
+
error = { code: 'unsupported_version', message: cause.message };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Every refusal goes to the sync log once, with the store's code and message (the orders list shows the cashier
|
|
386
|
+
// only the code's sentence, #269); unsupported_version stays an error.
|
|
387
|
+
if (result.status === 'rejected') outboxLogger[error?.code === 'unsupported_version' ? 'error' : 'warn']('Order refused by the store',
|
|
388
|
+
{ orderId: order.id, code: error?.code, message: error?.message?.slice(0, REFUSAL_LOG_MESSAGE_MAX) });
|
|
116
389
|
const updatedAt = new Date(now()).toISOString();
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
390
|
+
// Progress removes the stored serverFailures in the write that marks the order applied or rejected.
|
|
391
|
+
await current.incrementalModify((data) => {
|
|
392
|
+
delete data.serverFailures;
|
|
393
|
+
return Object.assign(data, result.status === 'rejected'
|
|
394
|
+
? { syncStatus: 'rejected' as const, error, updatedAt }
|
|
395
|
+
: { syncStatus: 'applied' as const, serverRefs: result.serverRefs,
|
|
396
|
+
...(result.warnings ? { warnings: result.warnings } : {}), updatedAt });
|
|
397
|
+
});
|
|
121
398
|
attempts.delete(order.commandId);
|
|
399
|
+
isolated.delete(order.commandId);
|
|
400
|
+
clocks.delete(order.commandId);
|
|
122
401
|
progressed = true;
|
|
123
402
|
await updateState();
|
|
124
403
|
}
|
|
125
|
-
if (!progressed)
|
|
404
|
+
if (!progressed) {
|
|
405
|
+
const floor = serverFailed(orders, alone, 'no_progress');
|
|
406
|
+
if (floor === undefined) continue;
|
|
407
|
+
return scheduleRetry('no_progress', floor);
|
|
408
|
+
}
|
|
409
|
+
runProgressed = true;
|
|
410
|
+
if (alone !== 'isolated') head = undefined;
|
|
411
|
+
if (walk && alone === 'probe') {
|
|
412
|
+
// The store took a probe, so it is up: the orders whose probes failed are isolated, and batching resumes.
|
|
413
|
+
for (const [id, reason] of walk.failed) isolate(id, reason);
|
|
414
|
+
walk = undefined;
|
|
415
|
+
}
|
|
126
416
|
backoff = initialBackoff;
|
|
127
|
-
state$.next({ ...state$.value, lastRetryReason: undefined });
|
|
417
|
+
state$.next({ ...state$.value, lastRetryReason: downgraded ? 'downgraded' : undefined });
|
|
128
418
|
} catch (error) {
|
|
129
419
|
scheduleRetry('error: ' + (error instanceof Error ? error.message : String(error)));
|
|
130
420
|
return;
|
|
@@ -135,12 +425,19 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
135
425
|
function flush(): Promise<void> {
|
|
136
426
|
if (running) return running;
|
|
137
427
|
stopped = false;
|
|
428
|
+
watchBackendMissing();
|
|
138
429
|
clearTimeout(timer);
|
|
139
430
|
timer = undefined;
|
|
140
431
|
running = Promise.resolve().then(run).finally(async () => {
|
|
432
|
+
try { await mirror(); } catch (cause) { outboxLogger.warn('Failed to store server failures', { cause }); }
|
|
141
433
|
try { await updateState({ sending: false }); } catch {}
|
|
142
434
|
running = undefined;
|
|
143
|
-
|
|
435
|
+
const missed = timerFiredDuringRun;
|
|
436
|
+
timerFiredDuringRun = false;
|
|
437
|
+
// A timer that fired during the run is not lost; a sale inserted while only isolated orders wait goes now.
|
|
438
|
+
if (!stopped && (missed || (insertedDuringRun || stoppedDuringRun) && (timer === undefined || insertedDuringRun && isolatedWait))) {
|
|
439
|
+
flush().catch(() => {});
|
|
440
|
+
}
|
|
144
441
|
});
|
|
145
442
|
return running;
|
|
146
443
|
}
|
|
@@ -163,6 +460,10 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
163
460
|
if (!changed) return data;
|
|
164
461
|
data.syncStatus = 'pending';
|
|
165
462
|
delete data.error;
|
|
463
|
+
delete data.serverFailures;
|
|
464
|
+
// A new commandId is a new idempotency key, so the next send chooses its version afresh.
|
|
465
|
+
delete data.sentVersion;
|
|
466
|
+
delete data.downgradedFrom;
|
|
166
467
|
// The server ledger stored the rejection under the old id; replaying it returns that rejection.
|
|
167
468
|
// For the codes requeue() accepts, the server created no order, so a fresh id cannot duplicate one.
|
|
168
469
|
data.commandId = uuidv7();
|
|
@@ -177,9 +478,11 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
177
478
|
},
|
|
178
479
|
start() {
|
|
179
480
|
stopped = false;
|
|
481
|
+
watchBackendMissing();
|
|
180
482
|
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
181
|
-
|
|
182
|
-
|
|
483
|
+
// An UPDATE without previousDocumentData counts as a change: a needless send costs a request, a skipped one a sale.
|
|
484
|
+
if (event.documentData?.syncStatus === 'pending' && (event.operation === 'INSERT' || event.operation === 'UPDATE' &&
|
|
485
|
+
!(event.previousDocumentData !== undefined && failuresOnly(event.previousDocumentData, event.documentData)))) {
|
|
183
486
|
insertedDuringRun = true;
|
|
184
487
|
flush().catch(() => {});
|
|
185
488
|
}
|
|
@@ -192,6 +495,7 @@ export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
|
192
495
|
timer = undefined;
|
|
193
496
|
subscription?.unsubscribe();
|
|
194
497
|
subscription = undefined;
|
|
498
|
+
missingSubscription?.unsubscribe(); missingSubscription = undefined;
|
|
195
499
|
state$.next({ ...state$.value, nextAttemptAt: undefined });
|
|
196
500
|
},
|
|
197
501
|
};
|