@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1
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/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +32 -1
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +84 -6
- package/server/core/price-list.js +348 -0
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +16 -4
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +23 -2
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +74 -21
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +51 -2
- package/server/integrations/seazona/lanes.js +251 -62
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +116 -0
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +5 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -16
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/monitoring.js +14 -0
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -9,16 +9,21 @@ import {
|
|
|
9
9
|
orderListSignature,
|
|
10
10
|
text,
|
|
11
11
|
} from './map.js';
|
|
12
|
+
import { runHistoryBackfill } from './history/backfill.js';
|
|
12
13
|
import { seazonaOrders } from './schema.js';
|
|
13
14
|
import {
|
|
14
15
|
claimLane,
|
|
15
16
|
clearOrderFailure,
|
|
17
|
+
clearRecordFailures,
|
|
16
18
|
finishLane,
|
|
17
19
|
markOrderDeleted,
|
|
18
20
|
readKnown,
|
|
19
21
|
readLaneStates,
|
|
20
22
|
readQuarantinedOrders,
|
|
23
|
+
readQuarantinedRecordCounts,
|
|
24
|
+
readQuarantinedRecords,
|
|
21
25
|
recordOrderFailure,
|
|
26
|
+
recordRecordFailure,
|
|
22
27
|
selectActiveOrders,
|
|
23
28
|
touchSynced,
|
|
24
29
|
upsertRecords,
|
|
@@ -34,46 +39,86 @@ import {
|
|
|
34
39
|
// clients changed clients since a cursor, every 30 min
|
|
35
40
|
// products the product list, daily
|
|
36
41
|
// users the lab user list, daily
|
|
42
|
+
// history past orders, a few calls per run, only while a person
|
|
43
|
+
// has started a backfill (history/backfill.js)
|
|
37
44
|
//
|
|
38
45
|
// Issue #12 holds for every order read: a 404 marks the order deleted in
|
|
39
46
|
// Seazona, an empty answer is a failure, each failure is recorded per order
|
|
40
47
|
// and quarantines it after a few in a row, and a lane fails only when most of
|
|
41
48
|
// its reads fail. Errors and /health name each failing order and its status.
|
|
49
|
+
//
|
|
50
|
+
// Issue #125: a list at the row cap is read on, not held: invoices and
|
|
51
|
+
// clients from the last row they answered, orders page by page. A lane holds
|
|
52
|
+
// only where Seazona gives no way on.
|
|
53
|
+
//
|
|
54
|
+
// Issue #101 holds for every lane: invoices and clients quarantine a record
|
|
55
|
+
// after a few failed reads too, so one bad record never holds a cursor. A
|
|
56
|
+
// list call that Seazona refuses (400, 401, 403, 404) means the path or the
|
|
57
|
+
// API client is wrong, and no wait fixes it: the lane stops and runs again
|
|
58
|
+
// only after a person saves the settings or Flow restarts on a new release.
|
|
59
|
+
// Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
|
|
42
60
|
|
|
43
|
-
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users']);
|
|
61
|
+
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history']);
|
|
44
62
|
|
|
45
63
|
const DAY_MS = 24 * 60 * 60_000;
|
|
46
64
|
const MAX_REPORTED = 25;
|
|
47
65
|
const FINAL_STATUS_PATTERN = 'shipped|delivered|cancelled|canceled|void|closed|archived|deleted';
|
|
48
66
|
|
|
49
67
|
class LaneError extends Error {
|
|
50
|
-
constructor(message, { code, details = null, held = false, retryAfterMs = null } = {}) {
|
|
68
|
+
constructor(message, { code, details = null, held = false, retryAfterMs = null, progress = null } = {}) {
|
|
51
69
|
super(message);
|
|
52
70
|
this.code = code;
|
|
53
71
|
this.details = details;
|
|
54
72
|
this.held = held;
|
|
55
73
|
this.retryAfterMs = retryAfterMs;
|
|
74
|
+
// What the lane read before it stopped: { cursor }, kept with the failure.
|
|
75
|
+
this.progress = progress;
|
|
56
76
|
}
|
|
57
77
|
}
|
|
58
78
|
|
|
59
|
-
function listOrFail(list, what
|
|
79
|
+
function listOrFail(list, what) {
|
|
60
80
|
if (!Array.isArray(list)) throw new LaneError(`Seazona ${what} answer was not a list`, { code: 'seazona_list_invalid' });
|
|
61
|
-
// Seazona documents no paging. A list exactly at the cap may be cut off,
|
|
62
|
-
// so hold the cursor rather than trust it.
|
|
63
|
-
if (list.length === sync.listCap) {
|
|
64
|
-
throw new LaneError(`Seazona ${what} answer has exactly ${sync.listCap} rows and may be cut off; the cursor is held`, {
|
|
65
|
-
code: 'seazona_list_at_cap',
|
|
66
|
-
held: true,
|
|
67
|
-
details: { observedCount: list.length, listCap: sync.listCap },
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
81
|
return list;
|
|
71
82
|
}
|
|
72
83
|
|
|
84
|
+
// Issue #125: a list at the cap is read on, page by page or from its last
|
|
85
|
+
// row. The lane holds only where it cannot go on.
|
|
86
|
+
function cannotGoOn(what, sync, reason, progress) {
|
|
87
|
+
return new LaneError(`Seazona ${what} answer is full (${sync.listCap} rows) and cannot be read further (${reason}); the cursor is held`, {
|
|
88
|
+
code: 'seazona_list_at_cap',
|
|
89
|
+
held: true,
|
|
90
|
+
details: { listCap: sync.listCap, reason },
|
|
91
|
+
progress,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Seazona writes lastModified in UTC with no zone suffix.
|
|
96
|
+
function parseUtc(value) {
|
|
97
|
+
const stamp = text(value);
|
|
98
|
+
if (!stamp) return NaN;
|
|
99
|
+
return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(stamp) ? stamp : `${stamp}Z`);
|
|
100
|
+
}
|
|
101
|
+
|
|
73
102
|
// A failure that is about Seazona or the rate limit, not one record: the
|
|
74
103
|
// whole lane stops and waits.
|
|
75
104
|
function isLaneLevel(error) {
|
|
76
|
-
return error?.statusCode === 429 || error?.
|
|
105
|
+
return error?.statusCode === 429 || error?.statusCode === 401 || error?.statusCode === 403
|
|
106
|
+
|| error?.kind === 'governor_timeout' || error?.kind === 'calls_stopped';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Answers how a lane that threw should wait: { status, heldReason, delayMs }.
|
|
110
|
+
// A null delay means the lane is stopped until a person or a release.
|
|
111
|
+
const REJECTED = new Set([400, 401, 403, 404]);
|
|
112
|
+
function laneOutcome(error, state, sync) {
|
|
113
|
+
if (error?.kind === 'calls_stopped') {
|
|
114
|
+
if (error.reason === 'halted') return { status: 'stopped', heldReason: error.code, delayMs: null };
|
|
115
|
+
return { status: 'failed', heldReason: null, delayMs: Math.max(sync.retryBaseMs, error.retryAfterMs ?? 0) };
|
|
116
|
+
}
|
|
117
|
+
if (error?.name === 'SeazonaApiError' && REJECTED.has(error.statusCode)) {
|
|
118
|
+
const auth = error.statusCode === 401 || error.statusCode === 403;
|
|
119
|
+
return { status: 'stopped', heldReason: auth ? 'seazona_api_halted' : 'seazona_request_rejected', delayMs: null };
|
|
120
|
+
}
|
|
121
|
+
return { status: error?.held ? 'held' : 'failed', heldReason: error?.held ? error.code : null, delayMs: failureDelayMs(error, state, sync) };
|
|
77
122
|
}
|
|
78
123
|
|
|
79
124
|
function describeReadFailure(error) {
|
|
@@ -156,12 +201,16 @@ function addSummary(total, summary) {
|
|
|
156
201
|
// Invoices and clients: a list since a cursor, then a detail read for each
|
|
157
202
|
// row whose list fields changed.
|
|
158
203
|
async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signatureOf, detailPath, map }) {
|
|
159
|
-
const { db, client, at } = ctx;
|
|
204
|
+
const { db, client, sync, at } = ctx;
|
|
160
205
|
const keyed = items.map((item) => ({ item, key: keyOf(item) })).filter((entry) => entry.key);
|
|
161
206
|
const known = await readKnown(db, mirror, keyed.map((entry) => entry.key));
|
|
207
|
+
const quarantined = await readQuarantinedRecords(db, mirror, keyed.map((entry) => entry.key), { at });
|
|
162
208
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
163
209
|
const unchanged = [];
|
|
210
|
+
const read = [];
|
|
164
211
|
const failures = [];
|
|
212
|
+
const skipped = [];
|
|
213
|
+
let newlyQuarantined = 0;
|
|
165
214
|
let detailReads = 0;
|
|
166
215
|
for (const { item, key } of keyed) {
|
|
167
216
|
const signature = signatureOf(item);
|
|
@@ -169,6 +218,11 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
169
218
|
unchanged.push(key);
|
|
170
219
|
continue;
|
|
171
220
|
}
|
|
221
|
+
// Issue #101: a quarantined record costs no call until its wait ends.
|
|
222
|
+
if (quarantined.has(key)) {
|
|
223
|
+
skipped.push(key);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
172
226
|
const id = idOf(item);
|
|
173
227
|
let detail = null;
|
|
174
228
|
if (id) {
|
|
@@ -178,21 +232,92 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
178
232
|
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${key}`);
|
|
179
233
|
} catch (error) {
|
|
180
234
|
if (isLaneLevel(error)) throw error;
|
|
181
|
-
|
|
235
|
+
const failure = describeReadFailure(error);
|
|
236
|
+
const recorded = await recordRecordFailure(db, mirror, key, failure, {
|
|
237
|
+
at,
|
|
238
|
+
quarantineFailures: sync.orderQuarantineFailures,
|
|
239
|
+
quarantineMs: sync.orderQuarantineMs,
|
|
240
|
+
});
|
|
241
|
+
if (recorded.quarantinedUntil) newlyQuarantined += 1;
|
|
242
|
+
failures.push({ id: key, ...failure, ...recorded });
|
|
182
243
|
continue;
|
|
183
244
|
}
|
|
184
245
|
}
|
|
246
|
+
read.push(key);
|
|
185
247
|
addSummary(summary, await upsertRecords(db, mirror, [map(item, detail)], { at, listSignatures: new Map([[key, signature]]) }));
|
|
186
248
|
}
|
|
187
249
|
await touchSynced(db, mirror, unchanged, { at });
|
|
250
|
+
await clearRecordFailures(db, mirror, read);
|
|
188
251
|
summary.unchanged += unchanged.length;
|
|
189
|
-
const
|
|
190
|
-
|
|
252
|
+
const extra = skipped.length ? { quarantinedIds: skipped.slice(0, MAX_REPORTED) } : {};
|
|
253
|
+
const failureDetails = judgeReads(`${what} reads`, detailReads, failures, extra);
|
|
254
|
+
// A quarantined record no longer holds the cursor; a record that may still
|
|
255
|
+
// be read again does.
|
|
256
|
+
const anyFailed = failures.some((failure) => !failure.quarantinedUntil);
|
|
257
|
+
return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
|
|
191
258
|
}
|
|
192
259
|
|
|
193
|
-
|
|
260
|
+
// Where the next changed-list read starts. A run that stopped inside a
|
|
261
|
+
// window keeps the exact start of the next part (`cursor.since`); otherwise
|
|
262
|
+
// the last finished run's start, less the overlap.
|
|
263
|
+
function cursorSince(ctx, fallback) {
|
|
264
|
+
const exact = ctx.state?.cursor?.since;
|
|
265
|
+
if (typeof exact === 'string') return exact;
|
|
194
266
|
const cursorMs = ctx.state?.cursorTime?.getTime();
|
|
195
|
-
return
|
|
267
|
+
return Number.isFinite(cursorMs) ? new Date(cursorMs - ctx.sync.cursorOverlapMs).toISOString() : fallback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function mergeDetails(total, details) {
|
|
271
|
+
if (!details) return total;
|
|
272
|
+
if (!total) return details;
|
|
273
|
+
return {
|
|
274
|
+
...total,
|
|
275
|
+
attempted: (total.attempted ?? 0) + (details.attempted ?? 0),
|
|
276
|
+
failed: (total.failed ?? 0) + (details.failed ?? 0),
|
|
277
|
+
failures: [...(total.failures ?? []), ...(details.failures ?? [])].slice(0, MAX_REPORTED),
|
|
278
|
+
...(total.quarantinedIds || details.quarantinedIds
|
|
279
|
+
? { quarantinedIds: [...(total.quarantinedIds ?? []), ...(details.quarantinedIds ?? [])].slice(0, MAX_REPORTED) }
|
|
280
|
+
: {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Invoices and clients: Seazona answers changes since a time in one list,
|
|
285
|
+
// with no paging, cut at the cap. The guide says the invoice list answers
|
|
286
|
+
// changes on or after the time, oldest first, so a full list is one part of
|
|
287
|
+
// the window: the next part starts at its last row (less `stepBackMs`, for a
|
|
288
|
+
// list that answers strictly after). Rows out of order, or a full list inside
|
|
289
|
+
// one second, cannot go on: the lane holds, and keeps where it got to. A run
|
|
290
|
+
// reads at most `listPagesPerRun` parts; the next run goes on from the exact
|
|
291
|
+
// start it stored.
|
|
292
|
+
async function walkChangedList(ctx, { what, firstSince, stepBackMs, list, read }) {
|
|
293
|
+
const { sync, at, state } = ctx;
|
|
294
|
+
const total = { summary: { created: 0, changed: 0, unchanged: 0 }, detailReads: 0, listCount: 0, failureDetails: null, newlyQuarantined: 0, quarantined: 0 };
|
|
295
|
+
let since = firstSince;
|
|
296
|
+
for (let pages = 1; ; pages += 1) {
|
|
297
|
+
const items = listOrFail(await list(since), `${what} list`);
|
|
298
|
+
const result = await read(items);
|
|
299
|
+
addSummary(total.summary, result.summary);
|
|
300
|
+
total.detailReads += result.detailReads;
|
|
301
|
+
total.listCount += result.listCount;
|
|
302
|
+
total.newlyQuarantined += result.newlyQuarantined;
|
|
303
|
+
total.quarantined += result.quarantined;
|
|
304
|
+
total.failureDetails = mergeDetails(total.failureDetails, result.failureDetails);
|
|
305
|
+
const keep = { ...total, pages, since: firstSince || null };
|
|
306
|
+
// A failed read keeps this part's start, so the next run reads it again.
|
|
307
|
+
if (result.anyFailed) return { ...keep, anyFailed: true, cursorTime: state?.cursorTime ?? null, cursor: { since } };
|
|
308
|
+
if (items.length < sync.listCap) return { ...keep, cursorTime: at, cursor: {} };
|
|
309
|
+
const sinceMs = since ? parseUtc(since) : -Infinity;
|
|
310
|
+
let lastMs = -Infinity;
|
|
311
|
+
for (const item of items) {
|
|
312
|
+
const ms = parseUtc(item?.lastModified);
|
|
313
|
+
if (!Number.isFinite(ms) || ms < lastMs) throw cannotGoOn(`${what} list`, sync, 'rows are not oldest first', { cursor: { since } });
|
|
314
|
+
lastMs = ms;
|
|
315
|
+
}
|
|
316
|
+
const next = lastMs - stepBackMs;
|
|
317
|
+
if (!(next > sinceMs)) throw cannotGoOn(`${what} list`, sync, 'one second holds more than the cap', { cursor: { since } });
|
|
318
|
+
since = new Date(next).toISOString();
|
|
319
|
+
if (pages >= sync.listPagesPerRun) return { ...keep, cursorTime: state?.cursorTime ?? null, cursor: { since } };
|
|
320
|
+
}
|
|
196
321
|
}
|
|
197
322
|
|
|
198
323
|
const floorUtcDay = (ms) => {
|
|
@@ -206,24 +331,43 @@ export function formatOrderedDay(ms) {
|
|
|
206
331
|
return new Date(floorUtcDay(ms)).toISOString().replace(/\.\d{3}Z$/, '');
|
|
207
332
|
}
|
|
208
333
|
|
|
334
|
+
// The guide's largest order page.
|
|
335
|
+
const ORDER_PAGE_MAX = 500;
|
|
336
|
+
|
|
337
|
+
// True when an order row was placed on a later day than `dayMs` (the start
|
|
338
|
+
// of a UTC day). `ordered` is the lab's local time with no zone, written
|
|
339
|
+
// yyyy-MM-ddTHH:mm:ss or dd/MM/yyyy; this compares calendar days only, so a
|
|
340
|
+
// lab ahead of UTC never ends a day early. Unknown dates are not after.
|
|
341
|
+
function placedAfter(item, dayMs) {
|
|
342
|
+
const raw = text(item?.ordered);
|
|
343
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
|
|
344
|
+
const local = /^(\d{2})\/(\d{2})\/(\d{4})/.exec(raw);
|
|
345
|
+
const placed = iso ? Date.UTC(+iso[1], +iso[2] - 1, +iso[3]) : local ? Date.UTC(+local[3], +local[2] - 1, +local[1]) : NaN;
|
|
346
|
+
return Number.isFinite(placed) && placed > dayMs;
|
|
347
|
+
}
|
|
348
|
+
|
|
209
349
|
const laneRuns = {
|
|
210
350
|
async invoices(ctx) {
|
|
211
351
|
const { client, sync, at } = ctx;
|
|
212
|
-
const
|
|
213
|
-
const items = listOrFail(await client.get('invoices/', { lastModified: since }), 'invoice list', sync);
|
|
352
|
+
const firstSince = cursorSince(ctx, new Date(floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS).toISOString());
|
|
214
353
|
const mapOptions = { keepPatientReference: sync.keepPatientReference };
|
|
215
|
-
|
|
216
|
-
mirror: 'invoices',
|
|
354
|
+
return walkChangedList(ctx, {
|
|
217
355
|
what: 'invoice',
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
356
|
+
firstSince,
|
|
357
|
+
// The guide: on or after lastModified (inclusive).
|
|
358
|
+
stepBackMs: 0,
|
|
359
|
+
list: (since) => client.get('invoices/', { lastModified: since }),
|
|
360
|
+
read: (items) => syncListWithDetail(ctx, {
|
|
361
|
+
mirror: 'invoices',
|
|
362
|
+
what: 'invoice',
|
|
363
|
+
items,
|
|
364
|
+
keyOf: (item) => text(item?.invoiceNumber),
|
|
365
|
+
idOf: (item) => text(item?.id),
|
|
366
|
+
signatureOf: invoiceListSignature,
|
|
367
|
+
detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
|
|
368
|
+
map: (item, detail) => mapInvoice(item, detail, mapOptions),
|
|
369
|
+
}),
|
|
224
370
|
});
|
|
225
|
-
// A failed invoice read keeps the cursor, so the next run reads it again.
|
|
226
|
-
return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since };
|
|
227
371
|
},
|
|
228
372
|
|
|
229
373
|
async orders_recent(ctx) {
|
|
@@ -231,15 +375,44 @@ const laneRuns = {
|
|
|
231
375
|
const nowMs = at.getTime();
|
|
232
376
|
const lookbackStart = floorUtcDay(nowMs - sync.orderLookbackDays * DAY_MS);
|
|
233
377
|
const doneThrough = Date.parse(ctx.state?.cursor?.completedThrough ?? '');
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
378
|
+
const partial = ctx.state?.cursor?.partial;
|
|
379
|
+
const partialDay = Date.parse(partial?.day ?? '');
|
|
380
|
+
// Go on inside a day a run did not finish; otherwise re-read the last
|
|
381
|
+
// finished day as well, so late orders on it are seen.
|
|
382
|
+
const resumeInside = Number.isFinite(partialDay) && partialDay >= lookbackStart;
|
|
383
|
+
const resume = resumeInside ? partialDay : Number.isFinite(doneThrough) ? Math.max(lookbackStart, doneThrough) : lookbackStart;
|
|
238
384
|
|
|
385
|
+
// Issue #125: the list is paged (page, pageSize). A full page goes on to
|
|
386
|
+
// the next page; a short page, or a page past the day, ends the day. The
|
|
387
|
+
// guide says the list answers orders on or after `ordered`; a list that
|
|
388
|
+
// answers one day works the same way.
|
|
389
|
+
const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
|
|
239
390
|
const byId = new Map();
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
391
|
+
const days = [];
|
|
392
|
+
let calls = 0;
|
|
393
|
+
let lastDone = null;
|
|
394
|
+
let stoppedAt = null;
|
|
395
|
+
for (let day = floorUtcDay(resume); day <= floorUtcDay(nowMs) && days.length < sync.orderDaysPerRun && !stoppedAt; day += DAY_MS) {
|
|
396
|
+
const label = `order list for ${formatOrderedDay(day)}`;
|
|
397
|
+
let page = resumeInside && day === floorUtcDay(resume) ? Math.max(1, Number(partial.page) || 1) : 1;
|
|
398
|
+
let previous = null;
|
|
399
|
+
for (;;) {
|
|
400
|
+
if (calls >= sync.listPagesPerRun) {
|
|
401
|
+
stoppedAt = { day, page };
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
calls += 1;
|
|
405
|
+
if (!days.includes(day)) days.push(day);
|
|
406
|
+
const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day), page, pageSize }), label);
|
|
407
|
+
const ids = list.map((item) => text(item?.id)).filter(Boolean);
|
|
408
|
+
// A Seazona that does not page answers the same rows again.
|
|
409
|
+
if (previous && ids.length && ids.every((id) => previous.has(id))) throw cannotGoOn(label, sync, 'the next page answers the same orders');
|
|
410
|
+
for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
|
|
411
|
+
if (list.length < pageSize || placedAfter(list[list.length - 1], day + DAY_MS)) break;
|
|
412
|
+
previous = new Set(ids);
|
|
413
|
+
page += 1;
|
|
414
|
+
}
|
|
415
|
+
if (!stoppedAt) lastDone = day;
|
|
243
416
|
}
|
|
244
417
|
const known = await readKnown(db, 'orders', [...byId.keys()]);
|
|
245
418
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
@@ -263,16 +436,20 @@ const laneRuns = {
|
|
|
263
436
|
await touchSynced(db, 'orders', unchanged, { at });
|
|
264
437
|
summary.unchanged += unchanged.length;
|
|
265
438
|
const failureDetails = judgeReads('recent order reads', detailReads, failures);
|
|
266
|
-
const
|
|
439
|
+
const through = lastDone ?? (Number.isFinite(doneThrough) ? doneThrough : null);
|
|
267
440
|
return {
|
|
268
441
|
summary,
|
|
269
442
|
detailReads,
|
|
270
443
|
listCount: byId.size,
|
|
444
|
+
listCalls: calls,
|
|
271
445
|
days: days.map(formatOrderedDay),
|
|
272
446
|
deletedIds: deleted,
|
|
273
447
|
failureDetails,
|
|
274
|
-
cursorTime: new Date(
|
|
275
|
-
cursor: {
|
|
448
|
+
cursorTime: through === null ? ctx.state?.cursorTime ?? null : new Date(through),
|
|
449
|
+
cursor: {
|
|
450
|
+
...(through === null ? {} : { completedThrough: new Date(through).toISOString() }),
|
|
451
|
+
...(stoppedAt ? { partial: { day: new Date(stoppedAt.day).toISOString(), page: stoppedAt.page } } : {}),
|
|
452
|
+
},
|
|
276
453
|
};
|
|
277
454
|
},
|
|
278
455
|
|
|
@@ -307,33 +484,40 @@ const laneRuns = {
|
|
|
307
484
|
},
|
|
308
485
|
|
|
309
486
|
async clients(ctx) {
|
|
310
|
-
const { client
|
|
487
|
+
const { client } = ctx;
|
|
311
488
|
// The first run reads the whole list; later runs read changes only.
|
|
312
489
|
// Seazona answers the whole list only to an empty `?lastModified=`; a
|
|
313
490
|
// bare path is a 404.
|
|
314
|
-
|
|
315
|
-
const reply = since ? await client.get('clients/', { lastModified: since }) : await client.get('clients/?lastModified=');
|
|
316
|
-
const items = listOrFail(reply, 'client list', sync);
|
|
317
|
-
const result = await syncListWithDetail(ctx, {
|
|
318
|
-
mirror: 'clients',
|
|
491
|
+
return walkChangedList(ctx, {
|
|
319
492
|
what: 'client',
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
493
|
+
firstSince: cursorSince(ctx, ''),
|
|
494
|
+
// The guide says "modified after": step back a second so a row in the
|
|
495
|
+
// last row's second is not lost.
|
|
496
|
+
stepBackMs: 1000,
|
|
497
|
+
list: (since) => (since ? client.get('clients/', { lastModified: since }) : client.get('clients/?lastModified=')),
|
|
498
|
+
read: (items) => syncListWithDetail(ctx, {
|
|
499
|
+
mirror: 'clients',
|
|
500
|
+
what: 'client',
|
|
501
|
+
items,
|
|
502
|
+
keyOf: (item) => text(item?.id),
|
|
503
|
+
idOf: (item) => text(item?.id),
|
|
504
|
+
signatureOf: clientListSignature,
|
|
505
|
+
detailPath: (id) => `clients/${encodeURIComponent(id)}`,
|
|
506
|
+
map: (item, detail) => mapClient(item, detail),
|
|
507
|
+
}),
|
|
326
508
|
});
|
|
327
|
-
return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since: since || null };
|
|
328
509
|
},
|
|
329
510
|
|
|
511
|
+
history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
|
|
330
512
|
products: (ctx) => syncReference(ctx, 'products'),
|
|
331
513
|
users: (ctx) => syncReference(ctx, 'users'),
|
|
332
514
|
};
|
|
333
515
|
|
|
334
516
|
async function syncReference(ctx, resource) {
|
|
335
517
|
const { db, client, sync, at } = ctx;
|
|
336
|
-
const list = listOrFail(await client.get(`${resource}/`), `${resource} list
|
|
518
|
+
const list = listOrFail(await client.get(`${resource}/`), `${resource} list`);
|
|
519
|
+
// The whole list in one answer, with no paging and no way to read on.
|
|
520
|
+
if (list.length === sync.listCap) throw cannotGoOn(`${resource} list`, sync, 'the list has no paging');
|
|
337
521
|
const records = list.map((item) => mapReference(resource, item)).filter((record) => record.id);
|
|
338
522
|
const { stored } = await upsertReference(db, resource, records, { at });
|
|
339
523
|
return { listCount: list.length, stored, cursorTime: at };
|
|
@@ -358,6 +542,7 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
358
542
|
for (const lane of wanted) {
|
|
359
543
|
const state = states.get(lane) ?? null;
|
|
360
544
|
const startMs = Number(now());
|
|
545
|
+
if (!force && !only.length && state?.status === 'stopped') continue;
|
|
361
546
|
if (!force && !only.length && state?.nextRunAt && state.nextRunAt.getTime() > startMs) continue;
|
|
362
547
|
const at = new Date(startMs);
|
|
363
548
|
if (!(await claimLane(db, lane, { at, stuckMs: sync.stuckLaneMs }))) {
|
|
@@ -382,18 +567,20 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
382
567
|
const { cursor, cursorTime, anyFailed, ...reported } = result;
|
|
383
568
|
results[lane] = { ok: true, ...reported };
|
|
384
569
|
} catch (error) {
|
|
385
|
-
const delayMs =
|
|
570
|
+
const { status, heldReason, delayMs } = laneOutcome(error, state, sync);
|
|
386
571
|
const message = text(error?.message) || 'Seazona lane failed';
|
|
572
|
+
const code = heldReason ?? error?.code ?? 'seazona_lane_failed';
|
|
387
573
|
await finishLane(db, lane, {
|
|
388
|
-
status
|
|
574
|
+
status,
|
|
389
575
|
lastAttemptAt: at,
|
|
390
|
-
nextRunAt: new Date(Number(now()) + delayMs),
|
|
576
|
+
nextRunAt: delayMs === null ? null : new Date(Number(now()) + delayMs),
|
|
577
|
+
...(error?.progress?.cursor ? { cursor: error.progress.cursor } : {}),
|
|
391
578
|
failureCount: (state?.failureCount ?? 0) + 1,
|
|
392
579
|
lastError: message,
|
|
393
|
-
failureDetails: error?.details ?? null,
|
|
394
|
-
heldReason
|
|
580
|
+
failureDetails: error?.details ?? (Number.isInteger(error?.statusCode) ? { httpStatus: error.statusCode, path: error.path ?? null } : null),
|
|
581
|
+
heldReason,
|
|
395
582
|
});
|
|
396
|
-
results[lane] = { ok: false, error: message, code
|
|
583
|
+
results[lane] = { ok: false, status, error: message, code, details: error?.details ?? null, retryAfterMs: delayMs };
|
|
397
584
|
}
|
|
398
585
|
}
|
|
399
586
|
return { ok: Object.values(results).every((result) => result.ok), results };
|
|
@@ -407,6 +594,7 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
|
|
|
407
594
|
const states = await readLaneStates(db);
|
|
408
595
|
const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
|
|
409
596
|
const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
|
|
597
|
+
const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
|
|
410
598
|
const lanes = LANES.map((lane) => {
|
|
411
599
|
const state = states.get(lane);
|
|
412
600
|
const { freshnessMs } = settings.sync.lanes[lane];
|
|
@@ -424,10 +612,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
|
|
|
424
612
|
heldReason: state?.heldReason ?? null,
|
|
425
613
|
failureDetails: state?.failureDetails ?? null,
|
|
426
614
|
...(lane === 'orders_active' ? { quarantinedOrders } : {}),
|
|
615
|
+
...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
|
|
427
616
|
};
|
|
428
617
|
});
|
|
429
618
|
return {
|
|
430
|
-
ok: lanes.every((lane) =>
|
|
619
|
+
ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
|
|
431
620
|
lanes,
|
|
432
621
|
deletedOrders: Number(deleted),
|
|
433
622
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "seazona_record_read_failures" (
|
|
2
|
+
"resource" text NOT NULL,
|
|
3
|
+
"record_key" text NOT NULL,
|
|
4
|
+
"failure_count" integer NOT NULL,
|
|
5
|
+
"last_attempt_at" timestamp with time zone NOT NULL,
|
|
6
|
+
"last_http_status" integer,
|
|
7
|
+
"last_failure_kind" text,
|
|
8
|
+
"quarantined_until" timestamp with time zone,
|
|
9
|
+
CONSTRAINT "seazona_record_read_failures_resource_record_key_pk" PRIMARY KEY("resource","record_key")
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "circuit_open_until" timestamp with time zone DEFAULT 'epoch' NOT NULL;--> statement-breakpoint
|
|
13
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "circuit_opens" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
14
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "halted_reason" text;--> statement-breakpoint
|
|
15
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "halted_at" timestamp with time zone;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "seazona_history_batch_jobs" (
|
|
2
|
+
"batch_id" bigint NOT NULL,
|
|
3
|
+
"job_key" text NOT NULL,
|
|
4
|
+
"job" jsonb NOT NULL,
|
|
5
|
+
"record_hash" text NOT NULL,
|
|
6
|
+
"outcome" text,
|
|
7
|
+
"findings" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
8
|
+
"mirror_order_id" text,
|
|
9
|
+
"mirror_invoice_number" text,
|
|
10
|
+
"client_id" text,
|
|
11
|
+
"decision" text,
|
|
12
|
+
"decided_by" text,
|
|
13
|
+
"decided_at" timestamp with time zone,
|
|
14
|
+
CONSTRAINT "seazona_history_batch_jobs_batch_id_job_key_pk" PRIMARY KEY("batch_id","job_key")
|
|
15
|
+
);
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE TABLE IF NOT EXISTS "seazona_history_batches" (
|
|
18
|
+
"id" bigserial PRIMARY KEY NOT NULL,
|
|
19
|
+
"file_name" text,
|
|
20
|
+
"file_sha256" text NOT NULL,
|
|
21
|
+
"profile" text NOT NULL,
|
|
22
|
+
"columns" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
23
|
+
"ignored_columns" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
24
|
+
"status" text NOT NULL,
|
|
25
|
+
"counts" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
26
|
+
"created_by" text NOT NULL,
|
|
27
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
28
|
+
"reconciled_at" timestamp with time zone,
|
|
29
|
+
"applied_at" timestamp with time zone,
|
|
30
|
+
"applied_by" text
|
|
31
|
+
);
|
|
32
|
+
--> statement-breakpoint
|
|
33
|
+
CREATE TABLE IF NOT EXISTS "seazona_history_jobs" (
|
|
34
|
+
"job_key" text PRIMARY KEY NOT NULL,
|
|
35
|
+
"invoice_number" text,
|
|
36
|
+
"case_ref" text,
|
|
37
|
+
"client_id" text,
|
|
38
|
+
"company" text,
|
|
39
|
+
"doctor_name" text,
|
|
40
|
+
"status" text,
|
|
41
|
+
"job_date" text,
|
|
42
|
+
"due_date" text,
|
|
43
|
+
"shipped_date" text,
|
|
44
|
+
"lines" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
45
|
+
"subtotal_cents" bigint,
|
|
46
|
+
"total_cents" bigint,
|
|
47
|
+
"patient_reference" text,
|
|
48
|
+
"mirror_order_id" text,
|
|
49
|
+
"mirror_invoice_number" text,
|
|
50
|
+
"record_hash" text NOT NULL,
|
|
51
|
+
"batch_id" bigint NOT NULL,
|
|
52
|
+
"first_batch_id" bigint NOT NULL,
|
|
53
|
+
"first_imported_at" timestamp with time zone NOT NULL,
|
|
54
|
+
"last_changed_at" timestamp with time zone NOT NULL
|
|
55
|
+
);
|
|
56
|
+
--> statement-breakpoint
|
|
57
|
+
CREATE TABLE IF NOT EXISTS "seazona_history_rows" (
|
|
58
|
+
"batch_id" bigint NOT NULL,
|
|
59
|
+
"row_number" integer NOT NULL,
|
|
60
|
+
"job_key" text,
|
|
61
|
+
"fields" jsonb NOT NULL,
|
|
62
|
+
"problems" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
63
|
+
CONSTRAINT "seazona_history_rows_batch_id_row_number_pk" PRIMARY KEY("batch_id","row_number")
|
|
64
|
+
);
|
|
65
|
+
--> statement-breakpoint
|
|
66
|
+
CREATE INDEX IF NOT EXISTS "seazona_history_batches_file" ON "seazona_history_batches" USING btree ("file_sha256");--> statement-breakpoint
|
|
67
|
+
CREATE INDEX IF NOT EXISTS "seazona_history_jobs_invoice" ON "seazona_history_jobs" USING btree ("invoice_number");--> statement-breakpoint
|
|
68
|
+
CREATE INDEX IF NOT EXISTS "seazona_history_jobs_changed" ON "seazona_history_jobs" USING btree ("last_changed_at");
|