@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.
Files changed (76) hide show
  1. package/README.md +2 -0
  2. package/npm-shrinkwrap.json +188 -2
  3. package/package.json +2 -1
  4. package/server/api/alerts.js +49 -0
  5. package/server/api/invoicing.js +110 -3
  6. package/server/app.js +29 -2
  7. package/server/core/alerts.js +157 -0
  8. package/server/core/automations.js +32 -1
  9. package/server/core/history.js +65 -0
  10. package/server/core/invoice-pdf.js +266 -0
  11. package/server/core/invoicing-rules.js +12 -0
  12. package/server/core/job-board.js +25 -3
  13. package/server/core/line-mapping.js +36 -10
  14. package/server/core/pipeline.js +84 -6
  15. package/server/core/price-list.js +348 -0
  16. package/server/core/queue.js +22 -0
  17. package/server/core/sending.js +16 -4
  18. package/server/db/commands.js +42 -0
  19. package/server/db/doctor.js +168 -0
  20. package/server/db/migrations/0014_price_list.sql +34 -0
  21. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  22. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  23. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  24. package/server/db/migrations/meta/_journal.json +14 -0
  25. package/server/db/schema.js +44 -0
  26. package/server/flow-server.js +20 -1
  27. package/server/index.js +2 -1
  28. package/server/integrations/call-guard.js +362 -0
  29. package/server/integrations/port.js +23 -2
  30. package/server/integrations/seazona/client.js +6 -0
  31. package/server/integrations/seazona/feed.js +23 -1
  32. package/server/integrations/seazona/governor.js +74 -21
  33. package/server/integrations/seazona/history/backfill.js +208 -0
  34. package/server/integrations/seazona/history/csv.js +65 -0
  35. package/server/integrations/seazona/history/index.js +584 -0
  36. package/server/integrations/seazona/history/read.js +196 -0
  37. package/server/integrations/seazona/index.js +51 -2
  38. package/server/integrations/seazona/lanes.js +251 -62
  39. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  40. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  41. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  42. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  43. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  44. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  45. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  46. package/server/integrations/seazona/routes.js +145 -0
  47. package/server/integrations/seazona/scheduler.js +37 -1
  48. package/server/integrations/seazona/schema.js +116 -0
  49. package/server/integrations/seazona/settings.js +24 -3
  50. package/server/integrations/seazona/store.js +74 -0
  51. package/server/integrations/seazona/sync-once.js +1 -1
  52. package/server/integrations/seazona/tables.js +5 -0
  53. package/server/integrations/xero/client.js +27 -7
  54. package/server/integrations/xero/drafts.js +46 -8
  55. package/server/integrations/xero/import.js +125 -16
  56. package/server/integrations/xero/index.js +36 -7
  57. package/server/integrations/xero/lookup.js +26 -0
  58. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  60. package/server/integrations/xero/mirror.js +11 -2
  61. package/server/integrations/xero/oauth.js +4 -1
  62. package/server/integrations/xero/reconcile.js +173 -0
  63. package/server/integrations/xero/routes.js +35 -0
  64. package/server/integrations/xero/runtime.js +45 -4
  65. package/server/release-channel.js +3 -3
  66. package/server/ui/alerts.js +77 -0
  67. package/server/ui/app-page.js +1 -1
  68. package/server/ui/app.js +48 -14
  69. package/server/ui/clerk-appearance.js +42 -9
  70. package/server/ui/flow.css +31 -2
  71. package/server/ui/invoicing-settings.js +1 -1
  72. package/server/ui/invoicing.js +121 -8
  73. package/server/ui/line-mappings.js +3 -2
  74. package/server/ui/monitoring.js +14 -0
  75. package/server/ui/price-list.js +247 -0
  76. 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, sync) {
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?.kind === 'governor_timeout';
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
- failures.push({ id: key, ...describeReadFailure(error) });
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 failureDetails = judgeReads(`${what} reads`, detailReads, failures);
190
- return { summary, detailReads, listCount: items.length, failureDetails, anyFailed: failures.length > 0 };
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
- function cursorSince(ctx, fallbackMs) {
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 new Date(Number.isFinite(cursorMs) ? cursorMs - ctx.sync.cursorOverlapMs : fallbackMs).toISOString();
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 since = cursorSince(ctx, floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS);
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
- const result = await syncListWithDetail(ctx, {
216
- mirror: 'invoices',
354
+ return walkChangedList(ctx, {
217
355
  what: 'invoice',
218
- items,
219
- keyOf: (item) => text(item?.invoiceNumber),
220
- idOf: (item) => text(item?.id),
221
- signatureOf: invoiceListSignature,
222
- detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
223
- map: (item, detail) => mapInvoice(item, detail, mapOptions),
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
- // Re-read the last finished day as well, so late orders on it are seen.
235
- const resume = Number.isFinite(doneThrough) ? Math.max(lookbackStart, doneThrough) : lookbackStart;
236
- const days = [];
237
- for (let day = floorUtcDay(resume); day <= floorUtcDay(nowMs) && days.length < sync.orderDaysPerRun; day += DAY_MS) days.push(day);
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
- for (const day of days) {
241
- const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day) }), `order list for ${formatOrderedDay(day)}`, sync);
242
- for (const item of list) if (text(item?.id)) byId.set(text(item.id), item);
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 lastDay = days.length ? days[days.length - 1] : floorUtcDay(nowMs);
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(lastDay),
275
- cursor: { completedThrough: new Date(lastDay).toISOString() },
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, sync, at } = ctx;
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
- const since = ctx.state?.cursorTime ? cursorSince(ctx, 0) : '';
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
- items,
321
- keyOf: (item) => text(item?.id),
322
- idOf: (item) => text(item?.id),
323
- signatureOf: clientListSignature,
324
- detailPath: (id) => `clients/${encodeURIComponent(id)}`,
325
- map: (item, detail) => mapClient(item, detail),
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`, sync);
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 = failureDelayMs(error, state, sync);
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: error?.held ? 'held' : 'failed',
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: error?.held ? error.code : null,
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: error?.code ?? 'seazona_lane_failed', details: error?.details ?? null, retryAfterMs: delayMs };
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) => lane.status !== 'failed' && lane.status !== 'held'),
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,2 @@
1
+ ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "stopped_resources" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "seazona_api_requests" ADD COLUMN IF NOT EXISTS "resource" text;
@@ -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");