@arcforgelabs/flow 2026.10.30 → 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 (67) 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/history.js +65 -0
  9. package/server/core/invoice-pdf.js +266 -0
  10. package/server/core/invoicing-rules.js +12 -0
  11. package/server/core/job-board.js +25 -3
  12. package/server/core/line-mapping.js +36 -10
  13. package/server/core/pipeline.js +75 -3
  14. package/server/core/price-list.js +348 -0
  15. package/server/db/commands.js +42 -0
  16. package/server/db/doctor.js +168 -0
  17. package/server/db/migrations/0014_price_list.sql +34 -0
  18. package/server/db/migrations/0015_alert_dismissals.sql +17 -0
  19. package/server/db/migrations/meta/0014_snapshot.json +3773 -0
  20. package/server/db/migrations/meta/0015_snapshot.json +3854 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +44 -0
  23. package/server/flow-server.js +20 -1
  24. package/server/index.js +2 -1
  25. package/server/integrations/call-guard.js +362 -0
  26. package/server/integrations/port.js +20 -1
  27. package/server/integrations/seazona/feed.js +23 -1
  28. package/server/integrations/seazona/governor.js +50 -122
  29. package/server/integrations/seazona/history/backfill.js +208 -0
  30. package/server/integrations/seazona/history/csv.js +65 -0
  31. package/server/integrations/seazona/history/index.js +584 -0
  32. package/server/integrations/seazona/history/read.js +196 -0
  33. package/server/integrations/seazona/index.js +37 -1
  34. package/server/integrations/seazona/lanes.js +186 -50
  35. package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
  36. package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
  37. package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
  38. package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  40. package/server/integrations/seazona/routes.js +145 -0
  41. package/server/integrations/seazona/schema.js +94 -0
  42. package/server/integrations/seazona/settings.js +14 -3
  43. package/server/integrations/seazona/sync-once.js +1 -1
  44. package/server/integrations/seazona/tables.js +4 -0
  45. package/server/integrations/xero/client.js +27 -7
  46. package/server/integrations/xero/drafts.js +46 -8
  47. package/server/integrations/xero/import.js +125 -17
  48. package/server/integrations/xero/index.js +36 -7
  49. package/server/integrations/xero/lookup.js +26 -0
  50. package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
  51. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  52. package/server/integrations/xero/mirror.js +11 -2
  53. package/server/integrations/xero/oauth.js +4 -1
  54. package/server/integrations/xero/reconcile.js +173 -0
  55. package/server/integrations/xero/routes.js +35 -0
  56. package/server/integrations/xero/runtime.js +45 -4
  57. package/server/release-channel.js +3 -3
  58. package/server/ui/alerts.js +77 -0
  59. package/server/ui/app-page.js +1 -1
  60. package/server/ui/app.js +48 -14
  61. package/server/ui/clerk-appearance.js +42 -9
  62. package/server/ui/flow.css +31 -2
  63. package/server/ui/invoicing-settings.js +1 -1
  64. package/server/ui/invoicing.js +121 -8
  65. package/server/ui/line-mappings.js +3 -2
  66. package/server/ui/price-list.js +247 -0
  67. package/server/ui/routes.js +1 -1
@@ -0,0 +1,196 @@
1
+ import { hashOf, parseSeazonaDate, text } from '../map.js';
2
+
3
+ // Seazona's CSV export, read into Flow fields and grouped into jobs. Pure: no
4
+ // database and no network. docs/SEAZONA.md, "History import", lists the
5
+ // headers Flow knows.
6
+
7
+ // Flow field -> the headers Seazona's export may use for it. Headers compare
8
+ // without case, spaces or punctuation, so "Invoice #" and "invoice no" match.
9
+ // "Client Name" is left out on purpose: in Seazona it can mean the doctor or
10
+ // the practice, so a person maps it with `columns` when the file has it.
11
+ export const INVOICE_LINE_COLUMNS = Object.freeze({
12
+ invoiceNumber: ['invoice', 'invoice #', 'invoice number', 'invoice no', 'inv #', 'inv no'],
13
+ caseRef: ['case', 'case #', 'case number', 'case no', 'case id', 'order', 'order #', 'order number', 'order no', 'order id'],
14
+ jobDate: ['invoice date', 'date', 'date invoiced', 'invoiced'],
15
+ dueDate: ['due date', 'due'],
16
+ shippedDate: ['ship date', 'shipped', 'shipped date', 'date shipped'],
17
+ company: ['company name', 'company', 'practice', 'practice name', 'account name'],
18
+ accountNumber: ['account #', 'account number', 'account no'],
19
+ doctorName: ['doctor name', 'doctor', 'dentist', 'practitioner'],
20
+ patient: ['patient', 'patient name'],
21
+ status: ['status', 'case status'],
22
+ item: ['item', 'product', 'description', 'item description', 'product name', 'service'],
23
+ itemCode: ['item code', 'code', 'product code'],
24
+ quantity: ['qty', 'quantity', 'units'],
25
+ itemAmount: ['item amount', 'amount', 'line amount', 'line total', 'price'],
26
+ itemTaxable: ['item taxable', 'taxable'],
27
+ total: ['total', 'invoice total'],
28
+ tooth: ['tooth', 'teeth', 'tooth #'],
29
+ });
30
+
31
+ // A file is read only when it has these, and at least one date.
32
+ const REQUIRED = ['company', 'item', 'itemAmount'];
33
+ const DATES = ['jobDate', 'dueDate', 'shippedDate'];
34
+
35
+ const squash = (header) => text(header).toLowerCase().replace(/[^a-z0-9#]/g, '').replace(/#/g, 'number');
36
+
37
+ // Answers { columns: field -> header, ignored: [header], missing: [field] }.
38
+ // `override` (field -> header) wins over the guess; a null value unmaps it.
39
+ export function detectColumns(headers, override = {}) {
40
+ const byKey = new Map(headers.map((h) => [squash(h), h]));
41
+ const columns = {};
42
+ for (const [field, aliases] of Object.entries(INVOICE_LINE_COLUMNS)) {
43
+ const found = aliases.map((alias) => byKey.get(squash(alias))).find(Boolean);
44
+ if (found) columns[field] = found;
45
+ }
46
+ for (const [field, header] of Object.entries(override ?? {})) {
47
+ if (!(field in INVOICE_LINE_COLUMNS)) throw Object.assign(new Error(`Unknown field ${field}`), { code: 'invalid' });
48
+ if (header === null) delete columns[field];
49
+ else if (headers.includes(header)) columns[field] = header;
50
+ else throw Object.assign(new Error(`The file has no column "${header}"`), { code: 'invalid' });
51
+ }
52
+ const used = new Set(Object.values(columns));
53
+ const missing = REQUIRED.filter((field) => !columns[field]);
54
+ if (!DATES.some((field) => columns[field])) missing.push('date');
55
+ return { columns, ignored: headers.filter((h) => h && !used.has(h)), missing };
56
+ }
57
+
58
+ // "$1,234.50", "1234.5", "(12.00)" and "-12" to integer cents.
59
+ export function moneyCents(value) {
60
+ const raw = text(value);
61
+ if (!raw) return null;
62
+ const negative = /^\(.*\)$/.test(raw) || raw.startsWith('-');
63
+ const digits = raw.replace(/[()$,\s-]/g, '').replace(/^[A-Z]{3}/i, '');
64
+ if (!/^\d*(\.\d+)?$/.test(digits) || digits === '' || digits === '.') return undefined;
65
+ const cents = Math.round(Number(digits) * 100);
66
+ return negative ? -cents : cents;
67
+ }
68
+
69
+ function yesNo(value) {
70
+ const raw = text(value).toLowerCase();
71
+ if (['y', 'yes', 'true', '1', 'taxable', 'gst'].includes(raw)) return true;
72
+ if (['n', 'no', 'false', '0', 'exempt', 'gst free', 'none'].includes(raw)) return false;
73
+ return null;
74
+ }
75
+
76
+ // One CSV row to Flow fields and the problems that stop it. The patient field
77
+ // is read for grouping; the caller drops it unless the installation keeps a
78
+ // patient reference.
79
+ export function readRow(cells, headers, columns) {
80
+ const cell = (field) => {
81
+ const header = columns[field];
82
+ if (!header) return '';
83
+ return text(cells[headers.indexOf(header)]);
84
+ };
85
+ const problems = [];
86
+ const fields = {
87
+ invoiceNumber: cell('invoiceNumber') || null,
88
+ caseRef: cell('caseRef') || null,
89
+ company: cell('company') || null,
90
+ accountNumber: cell('accountNumber') || null,
91
+ doctorName: cell('doctorName') || null,
92
+ patient: cell('patient') || null,
93
+ status: cell('status') || null,
94
+ item: cell('item') || null,
95
+ itemCode: cell('itemCode') || null,
96
+ tooth: cell('tooth') || null,
97
+ };
98
+ for (const field of DATES) {
99
+ const raw = cell(field);
100
+ fields[field] = raw ? parseSeazonaDate(raw) : null;
101
+ if (raw && !fields[field]) problems.push(`bad_${field}`);
102
+ }
103
+ if (!DATES.some((field) => fields[field])) problems.push('missing_date');
104
+ if (!fields.company) problems.push('missing_company');
105
+ if (!fields.item) problems.push('missing_item');
106
+ const amount = moneyCents(cell('itemAmount'));
107
+ if (amount === undefined || amount === null) problems.push(amount === null ? 'missing_amount' : 'bad_amount');
108
+ fields.itemAmountCents = typeof amount === 'number' ? amount : null;
109
+ const total = moneyCents(cell('total'));
110
+ if (total === undefined) problems.push('bad_total');
111
+ fields.totalCents = typeof total === 'number' ? total : null;
112
+ const quantity = cell('quantity');
113
+ fields.quantity = quantity && Number.isFinite(Number(quantity)) ? Number(quantity) : null;
114
+ fields.itemTaxable = columns.itemTaxable ? yesNo(cell('itemTaxable')) : null;
115
+ return { fields, problems };
116
+ }
117
+
118
+ const sameText = (a, b) => text(a).toLowerCase() === text(b).toLowerCase();
119
+
120
+ // Where a row's job is found: its invoice number, else its case number, else
121
+ // the old app's grouping (date, practice, doctor and patient). The patient
122
+ // only separates jobs inside this file; it is never part of the stored key.
123
+ function groupOf(fields) {
124
+ if (fields.invoiceNumber) return `inv:${fields.invoiceNumber}`;
125
+ if (fields.caseRef) return `case:${fields.caseRef}`;
126
+ const date = fields.jobDate ?? fields.dueDate ?? fields.shippedDate;
127
+ return `grp:${[date, fields.company, fields.doctorName, fields.patient].map((v) => text(v).toLowerCase()).join('|')}`;
128
+ }
129
+
130
+ const withGst = (cents) => Math.round(cents * 1.1);
131
+
132
+ // Rows (each { rowNumber, fields, problems }) to jobs. Rows with problems
133
+ // join no job. Answers { jobs, rowKeys: Map(rowNumber -> jobKey) }. Each job
134
+ // carries its findings: { code, blocking }.
135
+ export function groupJobs(rows) {
136
+ const groups = new Map();
137
+ for (const row of rows) {
138
+ if (row.problems.length) continue;
139
+ const group = groupOf(row.fields);
140
+ if (!groups.has(group)) groups.set(group, []);
141
+ groups.get(group).push(row);
142
+ }
143
+ const jobs = [];
144
+ const rowKeys = new Map();
145
+ const taken = new Map();
146
+ for (const [group, members] of groups) {
147
+ const first = members[0].fields;
148
+ const findings = [];
149
+ for (const field of ['company', 'doctorName']) {
150
+ if (members.some((m) => !sameText(m.fields[field], first[field]))) findings.push({ code: `mixed_${field}`, blocking: true });
151
+ }
152
+ const totals = [...new Set(members.map((m) => m.fields.totalCents).filter((t) => t !== null))];
153
+ if (totals.length > 1) findings.push({ code: 'conflicting_totals', blocking: true });
154
+ const lines = members.map((m) => ({
155
+ description: m.fields.item,
156
+ code: m.fields.itemCode,
157
+ amountCents: m.fields.itemAmountCents,
158
+ ...(m.fields.quantity !== null ? { quantity: m.fields.quantity } : {}),
159
+ ...(m.fields.itemTaxable !== null ? { taxable: m.fields.itemTaxable } : {}),
160
+ ...(m.fields.tooth ? { tooth: m.fields.tooth } : {}),
161
+ }));
162
+ const subtotalCents = lines.reduce((sum, l) => sum + l.amountCents, 0);
163
+ const totalCents = totals.length === 1 ? totals[0] : null;
164
+ if (totalCents !== null && totalCents !== subtotalCents && totalCents !== withGst(subtotalCents)) {
165
+ const taxable = lines.filter((l) => l.taxable !== false).reduce((sum, l) => sum + l.amountCents, 0);
166
+ if (totalCents !== subtotalCents + Math.round(taxable * 0.1)) findings.push({ code: 'total_differs_from_lines', blocking: false });
167
+ }
168
+ const pick = (field) => members.map((m) => m.fields[field]).find(Boolean) ?? null;
169
+ const job = {
170
+ invoiceNumber: pick('invoiceNumber'),
171
+ caseRef: pick('caseRef'),
172
+ company: first.company,
173
+ accountNumber: pick('accountNumber'),
174
+ doctorName: first.doctorName,
175
+ status: pick('status'),
176
+ jobDate: pick('jobDate'),
177
+ dueDate: pick('dueDate'),
178
+ shippedDate: pick('shippedDate'),
179
+ lines,
180
+ subtotalCents,
181
+ totalCents,
182
+ patientReference: pick('patient'),
183
+ };
184
+ // A job with no Seazona number is keyed by what it is, without the patient.
185
+ let key = group;
186
+ if (group.startsWith('grp:')) {
187
+ const base = `grp:${hashOf({ ...job, patientReference: null }).slice(0, 24)}`;
188
+ const n = (taken.get(base) ?? 0) + 1;
189
+ taken.set(base, n);
190
+ key = n === 1 ? base : `${base}#${n}`;
191
+ }
192
+ jobs.push({ jobKey: key, job, findings });
193
+ for (const m of members) rowKeys.set(m.rowNumber, key);
194
+ }
195
+ return { jobs, rowKeys };
196
+ }
@@ -1,6 +1,10 @@
1
+ import { guardAlerts } from '../call-guard.js';
1
2
  import { createSeazonaClient } from './client.js';
2
- import { listClients, listWork, readBillableWork } from './feed.js';
3
+ import { listClients, listProducts, listWork, readBillableWork } from './feed.js';
4
+ import { historyJobIssues, historySummary, readHistoryWork } from './history/index.js';
3
5
  import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
6
+ import { SEAZONA_CALL_GUARD } from './governor.js';
7
+ import { createSeazonaRouter } from './routes.js';
4
8
  import { createSeazonaScheduler } from './scheduler.js';
5
9
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
6
10
  import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
@@ -58,6 +62,11 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
58
62
  bundled: true,
59
63
  schema: seazonaSchema,
60
64
  setup: SEAZONA_SETUP,
65
+ // The history import's routes (routes.js), under /api/seazona.
66
+ routes: ({ db, personAuth, log }) => createSeazonaRouter({ db: runtime?.db ?? db, personAuth, settings: current().settings, log }),
67
+ // Every Seazona call goes through this call guard, inside the rate
68
+ // governor (governor.js; docs/INTEGRATIONS.md, "Outbound calls").
69
+ callGuard: SEAZONA_CALL_GUARD,
61
70
 
62
71
  // `reconfigured` is true when a person saved the settings or turned
63
72
  // Seazona on (issue #101): that clears a halt after a refused API client,
@@ -102,9 +111,18 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
102
111
  scheduler: scheduler ? scheduler.status() : null,
103
112
  governor: await client.governor.status(),
104
113
  ...sync,
114
+ history: await historySummary(db),
105
115
  };
106
116
  },
107
117
 
118
+ // What only a person can clear, for the Integrations page
119
+ // (server/core/alerts.js): a refused API client halts every call until
120
+ // a person saves the settings. Reads Flow's database only.
121
+ async alerts() {
122
+ if (!runtime?.client.governor) return [];
123
+ return guardAlerts('Seazona', await runtime.client.governor.status(), { fix: 'Check the API client in the Seazona settings, then save them.' });
124
+ },
125
+
108
126
  // Billable work since a cursor: complete and shipped orders that
109
127
  // changed, with the Seazona invoice that carries each one. Orders
110
128
  // deleted in Seazona are never billable; they come back in `withdrawn`
@@ -113,6 +131,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
113
131
  return readBillableWork(current().db, { cursor, ...options });
114
132
  },
115
133
 
134
+ // Past work from the history import (docs/SEAZONA.md, "History
135
+ // import"), as ledger jobs marked `historical`. Reads Flow's copy only.
136
+ async pullHistoryJobs(cursor = null, options = {}) {
137
+ return readHistoryWork(current().db, { cursor, ...options });
138
+ },
139
+
116
140
  // Seazona clients by id and company name, for the identity map. Reads
117
141
  // Flow's copy only.
118
142
  async listCustomers() {
@@ -124,6 +148,18 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
124
148
  async listWork() {
125
149
  return listWork(current().db, { now: now() });
126
150
  },
151
+
152
+ // History jobs a person must decide, as issues on their job cards,
153
+ // each with a link to the reconciliation. Reads Flow's copy only.
154
+ async jobIssues({ refs = [] } = {}) {
155
+ return historyJobIssues(current().db, refs);
156
+ },
157
+
158
+ // Seazona's products, for the price list (server/core/price-list.js).
159
+ // Reads Flow's copy only.
160
+ async listPriceItems() {
161
+ return listProducts(current().db);
162
+ },
127
163
  },
128
164
  };
129
165
  }
@@ -9,6 +9,7 @@ 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,
@@ -38,12 +39,18 @@ import {
38
39
  // clients changed clients since a cursor, every 30 min
39
40
  // products the product list, daily
40
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)
41
44
  //
42
45
  // Issue #12 holds for every order read: a 404 marks the order deleted in
43
46
  // Seazona, an empty answer is a failure, each failure is recorded per order
44
47
  // and quarantines it after a few in a row, and a lane fails only when most of
45
48
  // its reads fail. Errors and /health name each failing order and its status.
46
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
+ //
47
54
  // Issue #101 holds for every lane: invoices and clients quarantine a record
48
55
  // after a few failed reads too, so one bad record never holds a cursor. A
49
56
  // list call that Seazona refuses (400, 401, 403, 404) means the path or the
@@ -51,36 +58,47 @@ import {
51
58
  // only after a person saves the settings or Flow restarts on a new release.
52
59
  // Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
53
60
 
54
- 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']);
55
62
 
56
63
  const DAY_MS = 24 * 60 * 60_000;
57
64
  const MAX_REPORTED = 25;
58
65
  const FINAL_STATUS_PATTERN = 'shipped|delivered|cancelled|canceled|void|closed|archived|deleted';
59
66
 
60
67
  class LaneError extends Error {
61
- constructor(message, { code, details = null, held = false, retryAfterMs = null } = {}) {
68
+ constructor(message, { code, details = null, held = false, retryAfterMs = null, progress = null } = {}) {
62
69
  super(message);
63
70
  this.code = code;
64
71
  this.details = details;
65
72
  this.held = held;
66
73
  this.retryAfterMs = retryAfterMs;
74
+ // What the lane read before it stopped: { cursor }, kept with the failure.
75
+ this.progress = progress;
67
76
  }
68
77
  }
69
78
 
70
- function listOrFail(list, what, sync) {
79
+ function listOrFail(list, what) {
71
80
  if (!Array.isArray(list)) throw new LaneError(`Seazona ${what} answer was not a list`, { code: 'seazona_list_invalid' });
72
- // Seazona documents no paging. A list exactly at the cap may be cut off,
73
- // so hold the cursor rather than trust it.
74
- if (list.length === sync.listCap) {
75
- throw new LaneError(`Seazona ${what} answer has exactly ${sync.listCap} rows and may be cut off; the cursor is held`, {
76
- code: 'seazona_list_at_cap',
77
- held: true,
78
- details: { observedCount: list.length, listCap: sync.listCap },
79
- });
80
- }
81
81
  return list;
82
82
  }
83
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
+
84
102
  // A failure that is about Seazona or the rate limit, not one record: the
85
103
  // whole lane stops and waits.
86
104
  function isLaneLevel(error) {
@@ -239,9 +257,67 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
239
257
  return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
240
258
  }
241
259
 
242
- 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;
243
266
  const cursorMs = ctx.state?.cursorTime?.getTime();
244
- 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
+ }
245
321
  }
246
322
 
247
323
  const floorUtcDay = (ms) => {
@@ -255,24 +331,43 @@ export function formatOrderedDay(ms) {
255
331
  return new Date(floorUtcDay(ms)).toISOString().replace(/\.\d{3}Z$/, '');
256
332
  }
257
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
+
258
349
  const laneRuns = {
259
350
  async invoices(ctx) {
260
351
  const { client, sync, at } = ctx;
261
- const since = cursorSince(ctx, floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS);
262
- 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());
263
353
  const mapOptions = { keepPatientReference: sync.keepPatientReference };
264
- const result = await syncListWithDetail(ctx, {
265
- mirror: 'invoices',
354
+ return walkChangedList(ctx, {
266
355
  what: 'invoice',
267
- items,
268
- keyOf: (item) => text(item?.invoiceNumber),
269
- idOf: (item) => text(item?.id),
270
- signatureOf: invoiceListSignature,
271
- detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
272
- 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
+ }),
273
370
  });
274
- // A failed invoice read keeps the cursor, so the next run reads it again.
275
- return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since };
276
371
  },
277
372
 
278
373
  async orders_recent(ctx) {
@@ -280,15 +375,44 @@ const laneRuns = {
280
375
  const nowMs = at.getTime();
281
376
  const lookbackStart = floorUtcDay(nowMs - sync.orderLookbackDays * DAY_MS);
282
377
  const doneThrough = Date.parse(ctx.state?.cursor?.completedThrough ?? '');
283
- // Re-read the last finished day as well, so late orders on it are seen.
284
- const resume = Number.isFinite(doneThrough) ? Math.max(lookbackStart, doneThrough) : lookbackStart;
285
- const days = [];
286
- 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;
287
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);
288
390
  const byId = new Map();
289
- for (const day of days) {
290
- const list = listOrFail(await client.get('orders/', { ordered: formatOrderedDay(day) }), `order list for ${formatOrderedDay(day)}`, sync);
291
- 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;
292
416
  }
293
417
  const known = await readKnown(db, 'orders', [...byId.keys()]);
294
418
  const summary = { created: 0, changed: 0, unchanged: 0 };
@@ -312,16 +436,20 @@ const laneRuns = {
312
436
  await touchSynced(db, 'orders', unchanged, { at });
313
437
  summary.unchanged += unchanged.length;
314
438
  const failureDetails = judgeReads('recent order reads', detailReads, failures);
315
- const lastDay = days.length ? days[days.length - 1] : floorUtcDay(nowMs);
439
+ const through = lastDone ?? (Number.isFinite(doneThrough) ? doneThrough : null);
316
440
  return {
317
441
  summary,
318
442
  detailReads,
319
443
  listCount: byId.size,
444
+ listCalls: calls,
320
445
  days: days.map(formatOrderedDay),
321
446
  deletedIds: deleted,
322
447
  failureDetails,
323
- cursorTime: new Date(lastDay),
324
- 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
+ },
325
453
  };
326
454
  },
327
455
 
@@ -356,33 +484,40 @@ const laneRuns = {
356
484
  },
357
485
 
358
486
  async clients(ctx) {
359
- const { client, sync, at } = ctx;
487
+ const { client } = ctx;
360
488
  // The first run reads the whole list; later runs read changes only.
361
489
  // Seazona answers the whole list only to an empty `?lastModified=`; a
362
490
  // bare path is a 404.
363
- const since = ctx.state?.cursorTime ? cursorSince(ctx, 0) : '';
364
- const reply = since ? await client.get('clients/', { lastModified: since }) : await client.get('clients/?lastModified=');
365
- const items = listOrFail(reply, 'client list', sync);
366
- const result = await syncListWithDetail(ctx, {
367
- mirror: 'clients',
491
+ return walkChangedList(ctx, {
368
492
  what: 'client',
369
- items,
370
- keyOf: (item) => text(item?.id),
371
- idOf: (item) => text(item?.id),
372
- signatureOf: clientListSignature,
373
- detailPath: (id) => `clients/${encodeURIComponent(id)}`,
374
- 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
+ }),
375
508
  });
376
- return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since: since || null };
377
509
  },
378
510
 
511
+ history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
379
512
  products: (ctx) => syncReference(ctx, 'products'),
380
513
  users: (ctx) => syncReference(ctx, 'users'),
381
514
  };
382
515
 
383
516
  async function syncReference(ctx, resource) {
384
517
  const { db, client, sync, at } = ctx;
385
- 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');
386
521
  const records = list.map((item) => mapReference(resource, item)).filter((record) => record.id);
387
522
  const { stored } = await upsertReference(db, resource, records, { at });
388
523
  return { listCount: list.length, stored, cursorTime: at };
@@ -439,6 +574,7 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
439
574
  status,
440
575
  lastAttemptAt: at,
441
576
  nextRunAt: delayMs === null ? null : new Date(Number(now()) + delayMs),
577
+ ...(error?.progress?.cursor ? { cursor: error.progress.cursor } : {}),
442
578
  failureCount: (state?.failureCount ?? 0) + 1,
443
579
  lastError: message,
444
580
  failureDetails: error?.details ?? (Number.isInteger(error?.statusCode) ? { httpStatus: error.statusCode, path: error.path ?? null } : null),
@@ -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;