@tallyui/pos 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +2031 -43
- package/dist/index.js +4629 -205
- package/package.json +23 -12
- package/src/currency/currency-provider.tsx +12 -4
- package/src/currency/index.ts +1 -2
- package/src/index.ts +53 -4
- package/src/order/allocate-order-discount.ts +34 -0
- package/src/order/index.ts +8 -0
- package/src/order/order-builder.ts +256 -95
- package/src/order/order-manager.ts +26 -43
- package/src/order/tax-figures.ts +23 -0
- package/src/order/types.ts +73 -15
- package/src/outbox/backend-not-found.ts +27 -0
- package/src/outbox/http-transport.ts +67 -0
- package/src/outbox/index.ts +9 -0
- package/src/outbox/logger.ts +3 -0
- package/src/outbox/order-outbox.ts +502 -0
- package/src/outbox/register-outbox.ts +250 -0
- package/src/outbox/types.test-d.ts +22 -0
- package/src/outbox/types.ts +36 -0
- package/src/outbox/use-order-outbox.ts +175 -0
- package/src/pos-order/__fixtures__/order-create-v3.json +97 -0
- package/src/pos-order/command.ts +123 -0
- package/src/pos-order/device-id.ts +23 -0
- package/src/pos-order/finalize.ts +219 -0
- package/src/pos-order/index.ts +10 -0
- package/src/pos-order/needs-attention.ts +16 -0
- package/src/pos-order/open.ts +269 -0
- package/src/pos-order/same-sale.ts +26 -0
- package/src/pos-order/schema.ts +130 -0
- package/src/pos-order/types.ts +93 -0
- package/src/pos-order/uuidv7.ts +54 -0
- package/src/product/index.ts +2 -0
- package/src/product/search-products.ts +32 -0
- package/src/product/stock.ts +25 -0
- package/src/receipt/build-receipt-data.ts +39 -23
- package/src/receipt/types.ts +17 -10
- package/src/register/__fixtures__/closure-local-row.json +61 -0
- package/src/register/__fixtures__/closure.json +197 -0
- package/src/register/__fixtures__/corrections-dst.json +63 -0
- package/src/register/closure-document.ts +277 -0
- package/src/register/closure-rows.ts +65 -0
- package/src/register/document-labels.ts +46 -0
- package/src/register/expected.ts +66 -0
- package/src/register/export-csv.ts +74 -0
- package/src/register/facts.ts +109 -0
- package/src/register/index.ts +32 -0
- package/src/register/money.ts +18 -0
- package/src/register/movement-input.ts +59 -0
- package/src/register/register-commands.ts +147 -0
- package/src/register/register-count.denominations.ts +11 -0
- package/src/register/register-count.helpers.ts +64 -0
- package/src/register/register-document.ts +268 -0
- package/src/register/schemas.ts +175 -0
- package/src/register/session-store.ts +570 -0
- package/src/register/settled-figures.ts +98 -0
- package/src/register/use-register-session.ts +469 -0
- package/src/rxdb/index.ts +2 -0
- package/src/sale/cart.ts +23 -0
- package/src/sale/catalogue.ts +21 -0
- package/src/sale/index.ts +5 -0
- package/src/sale/use-sale.ts +394 -0
- package/src/store-settings/index.ts +5 -0
- package/src/store-settings/map-store-settings.ts +18 -0
- package/src/store-settings/resolve-store-settings.ts +65 -0
- package/src/store-settings/use-store-settings.ts +84 -0
- package/src/tax/exact.ts +218 -0
- package/src/tax/index.ts +4 -3
- package/src/tax/tax-provider.tsx +35 -12
- package/src/tax/types.ts +8 -6
- package/src/tender/index.ts +2 -0
- package/src/tender/tender-state.ts +326 -0
- package/src/currency/currency-provider.test.tsx +0 -36
- package/src/currency/format-currency.test.ts +0 -36
- package/src/currency/format-currency.ts +0 -18
- package/src/logging/logger.test.ts +0 -154
- package/src/logging/sinks.test.ts +0 -60
- package/src/order/discount-engine.test.ts +0 -152
- package/src/order/order-builder.test.ts +0 -246
- package/src/order/order-manager.test.ts +0 -209
- package/src/order/payment.test.ts +0 -91
- package/src/receipt/build-receipt-data.test.ts +0 -166
- package/src/repository/create-repository.test.ts +0 -166
- package/src/tax/calculate.test.ts +0 -59
- package/src/tax/calculate.ts +0 -32
- package/src/tax/tax-provider.test.tsx +0 -51
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import type { OrderCreateEnvelope } from '@tallyui/core';
|
|
2
|
+
import { deepEqual, type RxCollection, type RxDocumentData } from 'rxdb';
|
|
3
|
+
import { BehaviorSubject, type Observable, type Subscription } from 'rxjs';
|
|
4
|
+
import { outboxLogger } from './logger';
|
|
5
|
+
import { toOrderCreateEnvelope, UnsupportedOrderVersionError, uuidv7, type PosOrder, type PosOrderServerFailures } from '../pos-order';
|
|
6
|
+
import { freezeSentForm } from '../pos-order/finalize';
|
|
7
|
+
import { countFresh, readFresh } from '../rxdb';
|
|
8
|
+
import { createBackendNotFound, type BackendNotFound } from './backend-not-found';
|
|
9
|
+
import type { CommandTransport, OutboxState } from './types';
|
|
10
|
+
|
|
11
|
+
// Pause after three 401s since the server last accepted credentials.
|
|
12
|
+
const AUTH_FAILURES_BEFORE_PROMPT = 3;
|
|
13
|
+
|
|
14
|
+
// Rejections that may hide an order the server already created: resending under a new
|
|
15
|
+
// command id could duplicate it, so requeue() leaves these for manual reconciliation.
|
|
16
|
+
const NOT_REQUEUEABLE = new Set(['idempotency_mismatch']);
|
|
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
|
+
|
|
33
|
+
export interface OrderOutboxOptions {
|
|
34
|
+
collection: RxCollection<PosOrder>;
|
|
35
|
+
transport: CommandTransport<OrderCreateEnvelope>;
|
|
36
|
+
deviceId: string;
|
|
37
|
+
getMaxOrderCreateVersion?: () => number | undefined | Promise<number | undefined>;
|
|
38
|
+
refreshCapabilities?: () => Promise<void>;
|
|
39
|
+
batchSize?: number;
|
|
40
|
+
initialBackoffMs?: number;
|
|
41
|
+
maxBackoffMs?: number;
|
|
42
|
+
random?: () => number;
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OrderOutbox {
|
|
53
|
+
/** Sends pending orders until empty or retrying. Concurrent calls share one run. */
|
|
54
|
+
flush(): Promise<void>;
|
|
55
|
+
/** Moves rejected orders (all, or those whose id is listed) back to pending with a new commandId, then flushes.
|
|
56
|
+
* Orders rejected with idempotency_mismatch are left for reconciliation. Resolves to the number requeued. */
|
|
57
|
+
requeue(orderIds?: string[]): Promise<number>;
|
|
58
|
+
/** Watches for pending orders and sends them. */
|
|
59
|
+
start(): void;
|
|
60
|
+
/** Stops watching and retrying. */
|
|
61
|
+
stop(): void;
|
|
62
|
+
state$: Observable<OutboxState>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createOrderOutbox(options: OrderOutboxOptions): OrderOutbox {
|
|
66
|
+
const { collection, transport, deviceId } = options;
|
|
67
|
+
const batchSize = Math.min(options.batchSize ?? 10, 10);
|
|
68
|
+
const initialBackoff = options.initialBackoffMs ?? 1000;
|
|
69
|
+
const maxBackoff = options.maxBackoffMs ?? 60000;
|
|
70
|
+
const random = options.random ?? Math.random;
|
|
71
|
+
const now = options.now ?? Date.now;
|
|
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();
|
|
79
|
+
const attempts = new Map<string, number>();
|
|
80
|
+
let backoff = initialBackoff;
|
|
81
|
+
let unauthorizedSinceAccepted = 0;
|
|
82
|
+
let running: Promise<void> | undefined;
|
|
83
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
84
|
+
let subscription: Subscription | undefined;
|
|
85
|
+
let stopped = false;
|
|
86
|
+
let insertedDuringRun = false;
|
|
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
|
+
}
|
|
138
|
+
|
|
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.
|
|
173
|
+
async function updateState(patch: Partial<OutboxState> = {}) {
|
|
174
|
+
const pending = await countFresh(collection, { syncStatus: 'pending' });
|
|
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;
|
|
204
|
+
}
|
|
205
|
+
|
|
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) {
|
|
208
|
+
state$.next({ ...state$.value, lastRetryReason: reason });
|
|
209
|
+
if (stopped) { stoppedDuringRun = true; return; }
|
|
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);
|
|
215
|
+
state$.next({ ...state$.value, sending: false, nextAttemptAt: now() + delay });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function run() {
|
|
219
|
+
stoppedDuringRun = false;
|
|
220
|
+
runProgressed = false;
|
|
221
|
+
const sentAlone = new Set<string>();
|
|
222
|
+
while (!stopped) try {
|
|
223
|
+
if (!restored) await restore();
|
|
224
|
+
await updateState({ sending: true, nextAttemptAt: undefined });
|
|
225
|
+
insertedDuringRun = false;
|
|
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,
|
|
247
|
+
});
|
|
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);
|
|
265
|
+
const attempt = (attempts.get(order.commandId) ?? 0) + 1;
|
|
266
|
+
attempts.set(order.commandId, attempt);
|
|
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;
|
|
297
|
+
if (outcome.kind === 'retry') {
|
|
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);
|
|
307
|
+
}
|
|
308
|
+
if (outcome.kind === 'unauthorized') {
|
|
309
|
+
unauthorizedSinceAccepted++;
|
|
310
|
+
if (unauthorizedSinceAccepted < AUTH_FAILURES_BEFORE_PROMPT) return scheduleRetry('unauthorized');
|
|
311
|
+
state$.next({ ...state$.value, authRequired: true, lastRetryReason: 'unauthorized',
|
|
312
|
+
sending: false, nextAttemptAt: undefined });
|
|
313
|
+
return;
|
|
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
|
+
}
|
|
332
|
+
if (outcome.kind === 'refused') {
|
|
333
|
+
unauthorizedSinceAccepted = 0;
|
|
334
|
+
state$.next({ ...state$.value, refused: { status: outcome.status, reason: outcome.reason },
|
|
335
|
+
authRequired: false, lastRetryReason: 'refused', sending: false, nextAttemptAt: undefined });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
unauthorizedSinceAccepted = 0;
|
|
339
|
+
state$.next({ ...state$.value, authRequired: false, refused: undefined });
|
|
340
|
+
let progressed = false;
|
|
341
|
+
let downgraded = false;
|
|
342
|
+
for (const order of orders) {
|
|
343
|
+
const result = outcome.results.find((entry) => entry.id === order.commandId);
|
|
344
|
+
if (!result) continue;
|
|
345
|
+
// A primary-key lookup straight on the storage instance: skips the query cache (like
|
|
346
|
+
// readFresh, but without its full index scan here) and excludes a deleted document as not
|
|
347
|
+
// found. The commandId check catches an order requeued while this one was in flight.
|
|
348
|
+
const [stored] = await collection.storageInstance.findDocumentsById([order.id], false);
|
|
349
|
+
if (stored?.syncStatus !== 'pending' || stored.commandId !== order.commandId) continue;
|
|
350
|
+
const current = await collection.findOne(order.id).exec();
|
|
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) });
|
|
389
|
+
const updatedAt = new Date(now()).toISOString();
|
|
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
|
+
});
|
|
398
|
+
attempts.delete(order.commandId);
|
|
399
|
+
isolated.delete(order.commandId);
|
|
400
|
+
clocks.delete(order.commandId);
|
|
401
|
+
progressed = true;
|
|
402
|
+
await updateState();
|
|
403
|
+
}
|
|
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
|
+
}
|
|
416
|
+
backoff = initialBackoff;
|
|
417
|
+
state$.next({ ...state$.value, lastRetryReason: downgraded ? 'downgraded' : undefined });
|
|
418
|
+
} catch (error) {
|
|
419
|
+
scheduleRetry('error: ' + (error instanceof Error ? error.message : String(error)));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
stoppedDuringRun = true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function flush(): Promise<void> {
|
|
426
|
+
if (running) return running;
|
|
427
|
+
stopped = false;
|
|
428
|
+
watchBackendMissing();
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
timer = undefined;
|
|
431
|
+
running = Promise.resolve().then(run).finally(async () => {
|
|
432
|
+
try { await mirror(); } catch (cause) { outboxLogger.warn('Failed to store server failures', { cause }); }
|
|
433
|
+
try { await updateState({ sending: false }); } catch {}
|
|
434
|
+
running = undefined;
|
|
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
|
+
}
|
|
441
|
+
});
|
|
442
|
+
return running;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
updateState().catch(() => {});
|
|
446
|
+
return {
|
|
447
|
+
state$,
|
|
448
|
+
flush,
|
|
449
|
+
async requeue(orderIds) {
|
|
450
|
+
const orders = (await readFresh(collection, { selector: { syncStatus: 'rejected',
|
|
451
|
+
...(orderIds ? { id: { $in: orderIds } } : {}) } }))
|
|
452
|
+
.filter((order) => !NOT_REQUEUEABLE.has(order.error?.code ?? ''));
|
|
453
|
+
let count = 0;
|
|
454
|
+
for (const order of orders) {
|
|
455
|
+
const oldCommandId = order.commandId;
|
|
456
|
+
let changed = false;
|
|
457
|
+
// The modifier sees the stored state, even when findOne's cached result is stale.
|
|
458
|
+
await (await collection.findOne(order.id).exec())?.incrementalModify((data) => {
|
|
459
|
+
changed = data.syncStatus === 'rejected' && !NOT_REQUEUEABLE.has(data.error?.code ?? '');
|
|
460
|
+
if (!changed) return data;
|
|
461
|
+
data.syncStatus = 'pending';
|
|
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;
|
|
467
|
+
// The server ledger stored the rejection under the old id; replaying it returns that rejection.
|
|
468
|
+
// For the codes requeue() accepts, the server created no order, so a fresh id cannot duplicate one.
|
|
469
|
+
data.commandId = uuidv7();
|
|
470
|
+
data.updatedAt = new Date(now()).toISOString();
|
|
471
|
+
return data;
|
|
472
|
+
});
|
|
473
|
+
if (changed) { count++; attempts.delete(oldCommandId); }
|
|
474
|
+
}
|
|
475
|
+
await updateState();
|
|
476
|
+
if (count && !stopped) flush().catch(() => {});
|
|
477
|
+
return count;
|
|
478
|
+
},
|
|
479
|
+
start() {
|
|
480
|
+
stopped = false;
|
|
481
|
+
watchBackendMissing();
|
|
482
|
+
if (!subscription) subscription = collection.$.subscribe((event) => {
|
|
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)))) {
|
|
486
|
+
insertedDuringRun = true;
|
|
487
|
+
flush().catch(() => {});
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
if (!stopped) flush().catch(() => {});
|
|
491
|
+
},
|
|
492
|
+
stop() {
|
|
493
|
+
stopped = true;
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
timer = undefined;
|
|
496
|
+
subscription?.unsubscribe();
|
|
497
|
+
subscription = undefined;
|
|
498
|
+
missingSubscription?.unsubscribe(); missingSubscription = undefined;
|
|
499
|
+
state$.next({ ...state$.value, nextAttemptAt: undefined });
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|