@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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { and, count, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
|
2
|
+
import { CallsStopped, countsAsFailure, createCallGuard, normaliseRequestPath } from '../call-guard.js';
|
|
2
3
|
import { seazonaApiGovernor, seazonaApiRequests } from './schema.js';
|
|
3
4
|
import { MAX_REQUESTS_PER_MINUTE } from './settings.js';
|
|
4
5
|
|
|
6
|
+
export { countsAsFailure, normaliseRequestPath };
|
|
7
|
+
|
|
5
8
|
// The Seazona rate governor. Seazona sees every process that holds the same
|
|
6
9
|
// API client as one client, so the limit is kept in PostgreSQL, not in
|
|
7
10
|
// memory: a workspace and a deployment on one database share it.
|
|
@@ -10,18 +13,36 @@ import { MAX_REQUESTS_PER_MINUTE } from './settings.js';
|
|
|
10
13
|
// admitted in the last minute, the minimum gap since the last admission has
|
|
11
14
|
// passed, and no 429 cooldown is running. Admission takes a transaction-level
|
|
12
15
|
// advisory lock per client, so two processes never admit the same slot.
|
|
16
|
+
//
|
|
17
|
+
// Issue #101: a vendor can cut off API access after many failed calls, even
|
|
18
|
+
// inside the rate limit. So the governor also stops calls outright, through
|
|
19
|
+
// the shared call guard (../call-guard.js, issue #111) on these same tables:
|
|
20
|
+
// - a 401 or 403 halts every call until a person acts (the adapter restarts
|
|
21
|
+
// with new or saved settings, which calls resume());
|
|
22
|
+
// - `circuitFailures` failed calls inside `circuitWindowMs` open the circuit:
|
|
23
|
+
// no call for `circuitPauseMs`, then one probe call; a failed probe opens
|
|
24
|
+
// it again for twice as long, to at most 6 hours, and a success closes it;
|
|
25
|
+
// - `dailyFailureBudget` failed calls in one UTC day stop calls until the
|
|
26
|
+
// next day.
|
|
27
|
+
// A stopped call fails at once with SeazonaCallsStopped; it never waits. The
|
|
28
|
+
// lanes report each held failure once (lanes.js), so the guard does not.
|
|
29
|
+
|
|
30
|
+
// The call guard's tables and policy. The adapter declares it (index.js), and
|
|
31
|
+
// the shared contract suite runs on it (test/integration-call-guard.test.js).
|
|
32
|
+
export const SEAZONA_CALL_GUARD = Object.freeze({
|
|
33
|
+
integration: 'seazona',
|
|
34
|
+
name: 'Seazona',
|
|
35
|
+
tables: Object.freeze({ state: 'seazona_api_governor', calls: 'seazona_api_requests' }),
|
|
36
|
+
columns: Object.freeze({ key: 'client_key', at: 'admitted_at' }),
|
|
37
|
+
callDefaults: Object.freeze({ workflow: 'seazona' }),
|
|
38
|
+
lockName: 'seazona-governor',
|
|
39
|
+
policy: Object.freeze({ authStops: 'all' }),
|
|
40
|
+
});
|
|
13
41
|
|
|
14
42
|
const WINDOW_MS = 60_000;
|
|
15
43
|
const MAX_COOLDOWN_MS = 60_000;
|
|
16
|
-
|
|
17
44
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
18
45
|
|
|
19
|
-
// Path only: drop any host and query, and keep a safe character set.
|
|
20
|
-
export function normaliseRequestPath(value) {
|
|
21
|
-
const path = String(value || '').split(/[?#]/)[0].replace(/^[a-z]+:\/\/[^/]+/i, '');
|
|
22
|
-
return path.replace(/[^A-Za-z0-9/_.:-]+/g, '').slice(0, 160) || null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
46
|
function normaliseWorkflow(value) {
|
|
26
47
|
return String(value || 'seazona').trim().toLowerCase().replace(/[^a-z0-9:_-]+/g, '-').slice(0, 80) || 'seazona';
|
|
27
48
|
}
|
|
@@ -36,12 +57,27 @@ export class SeazonaGovernorTimeout extends Error {
|
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
|
|
60
|
+
// Calls are stopped: halted for a person, circuit open, or the day's failed
|
|
61
|
+
// calls are spent. `retryAfterMs` is null for a halt, which no wait clears.
|
|
62
|
+
// The shared CallsStopped, with Seazona's own words.
|
|
63
|
+
export class SeazonaCallsStopped extends CallsStopped {
|
|
64
|
+
constructor(reason, retryAfterMs = null) {
|
|
65
|
+
super({ integration: 'seazona', name: 'Seazona', reason, retryAfterMs });
|
|
66
|
+
if (reason === 'halted') this.message = 'Seazona calls are stopped until a person checks the API client (it was refused)';
|
|
67
|
+
this.name = 'SeazonaCallsStopped';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
export function createSeazonaGovernor({
|
|
40
72
|
db,
|
|
41
73
|
clientKey,
|
|
42
74
|
requestsPerMinute = MAX_REQUESTS_PER_MINUTE,
|
|
43
75
|
maxWaitMs = 120_000,
|
|
44
76
|
telemetryRetentionMs = 35 * 24 * 60 * 60_000,
|
|
77
|
+
circuitFailures = 5,
|
|
78
|
+
circuitWindowMs = 10 * 60_000,
|
|
79
|
+
circuitPauseMs = 5 * 60_000,
|
|
80
|
+
dailyFailureBudget = 50,
|
|
45
81
|
now = Date.now,
|
|
46
82
|
sleepFn = sleep,
|
|
47
83
|
} = {}) {
|
|
@@ -50,7 +86,14 @@ export function createSeazonaGovernor({
|
|
|
50
86
|
if (!key) throw new TypeError('clientKey is required');
|
|
51
87
|
const limit = Math.min(MAX_REQUESTS_PER_MINUTE, Math.max(1, Math.trunc(requestsPerMinute) || MAX_REQUESTS_PER_MINUTE));
|
|
52
88
|
const minIntervalMs = Math.ceil(WINDOW_MS / limit);
|
|
53
|
-
const lockKey =
|
|
89
|
+
const lockKey = `${SEAZONA_CALL_GUARD.lockName}:${key}`;
|
|
90
|
+
const guard = createCallGuard({
|
|
91
|
+
db,
|
|
92
|
+
...SEAZONA_CALL_GUARD,
|
|
93
|
+
policy: { ...SEAZONA_CALL_GUARD.policy, circuitFailures, circuitWindowMs, circuitPauseMs, dailyFailureBudget, telemetryRetentionMs },
|
|
94
|
+
report: false,
|
|
95
|
+
now,
|
|
96
|
+
});
|
|
54
97
|
|
|
55
98
|
async function tryAdmit(workflow, isRetry) {
|
|
56
99
|
return db.transaction(async (tx) => {
|
|
@@ -59,6 +102,8 @@ export function createSeazonaGovernor({
|
|
|
59
102
|
const nowAt = new Date(nowMs);
|
|
60
103
|
await tx.insert(seazonaApiGovernor).values({ clientKey: key }).onConflictDoNothing();
|
|
61
104
|
const [state] = await tx.select().from(seazonaApiGovernor).where(eq(seazonaApiGovernor.clientKey, key));
|
|
105
|
+
const stopped = await guard.stoppedNow(tx, key);
|
|
106
|
+
if (stopped) return { admitted: false, stopped };
|
|
62
107
|
const windowStart = new Date(nowMs - WINDOW_MS);
|
|
63
108
|
const [{ admitted, oldestMs }] = await tx
|
|
64
109
|
.select({ admitted: count(), oldestMs: sql`(extract(epoch from min(${seazonaApiRequests.admittedAt})) * 1000)::float8` })
|
|
@@ -90,6 +135,7 @@ export function createSeazonaGovernor({
|
|
|
90
135
|
for (;;) {
|
|
91
136
|
const decision = await tryAdmit(workflow, isRetry);
|
|
92
137
|
if (decision.admitted) return { requestId: decision.requestId, waitedMs };
|
|
138
|
+
if (decision.stopped) throw new SeazonaCallsStopped(decision.stopped.reason, decision.stopped.retryAfterMs);
|
|
93
139
|
const delay = Math.max(1, Math.min(decision.waitMs, maxWaitMs));
|
|
94
140
|
if (waitedMs + delay > maxWaitMs) throw new SeazonaGovernorTimeout(waitedMs, delay);
|
|
95
141
|
waitedMs += delay;
|
|
@@ -97,31 +143,32 @@ export function createSeazonaGovernor({
|
|
|
97
143
|
}
|
|
98
144
|
}
|
|
99
145
|
|
|
100
|
-
// Records how a request ended,
|
|
146
|
+
// Records how a request ended, any cooldown a 429 asked for, and what the
|
|
147
|
+
// ending does to the circuit (the shared guard). Answers { halted, opened }
|
|
148
|
+
// when this response stopped calls.
|
|
101
149
|
async function recordResponse({ requestId, path = null, statusCode = null, failureKind = null, durationMs = null, retryAfterMs = null } = {}) {
|
|
102
|
-
const nowMs = Number(now());
|
|
103
|
-
await db.update(seazonaApiRequests).set({
|
|
104
|
-
requestPath: normaliseRequestPath(path),
|
|
105
|
-
statusCode: Number.isInteger(statusCode) ? statusCode : null,
|
|
106
|
-
failureKind: failureKind ? String(failureKind).slice(0, 40) : null,
|
|
107
|
-
durationMs: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
|
|
108
|
-
}).where(and(eq(seazonaApiRequests.id, requestId), eq(seazonaApiRequests.clientKey, key)));
|
|
109
150
|
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
|
151
|
+
const nowMs = Number(now());
|
|
110
152
|
const until = new Date(nowMs + Math.min(retryAfterMs, MAX_COOLDOWN_MS));
|
|
111
153
|
await db.update(seazonaApiGovernor)
|
|
112
154
|
.set({ cooldownUntil: sql`greatest(${seazonaApiGovernor.cooldownUntil}, ${until})`, updatedAt: new Date(nowMs) })
|
|
113
155
|
.where(eq(seazonaApiGovernor.clientKey, key));
|
|
114
156
|
}
|
|
115
|
-
await
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
157
|
+
const { halted, opened } = await guard.record(key, { callId: requestId, path, statusCode, failureKind, durationMs });
|
|
158
|
+
return { ...(halted ? { halted } : {}), ...(opened ? { opened } : {}) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A person acted (new or saved settings, or the adapter turned on again):
|
|
162
|
+
// clear a halt and close the circuit. The day's failure budget stays.
|
|
163
|
+
async function resume() {
|
|
164
|
+
await guard.resume(key);
|
|
119
165
|
}
|
|
120
166
|
|
|
121
167
|
// Counts for /health: the last minute, and the last day by status.
|
|
122
168
|
async function status() {
|
|
123
169
|
const nowMs = Number(now());
|
|
124
170
|
const [state] = await db.select().from(seazonaApiGovernor).where(eq(seazonaApiGovernor.clientKey, key));
|
|
171
|
+
const calls = await guard.status(key);
|
|
125
172
|
const [{ lastMinute }] = await db.select({ lastMinute: count() }).from(seazonaApiRequests)
|
|
126
173
|
.where(and(eq(seazonaApiRequests.clientKey, key), gte(seazonaApiRequests.admittedAt, new Date(nowMs - WINDOW_MS))));
|
|
127
174
|
const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs) });
|
|
@@ -131,6 +178,12 @@ export function createSeazonaGovernor({
|
|
|
131
178
|
minIntervalMs,
|
|
132
179
|
lastMinute: Number(lastMinute),
|
|
133
180
|
cooldownUntil,
|
|
181
|
+
circuit: calls.circuit,
|
|
182
|
+
circuitOpenUntil: calls.circuitOpenUntil,
|
|
183
|
+
haltedReason: calls.haltedReason,
|
|
184
|
+
haltedAt: calls.haltedAt,
|
|
185
|
+
failedToday: calls.failedToday,
|
|
186
|
+
dailyFailureBudget: calls.dailyFailureBudget,
|
|
134
187
|
lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries },
|
|
135
188
|
};
|
|
136
189
|
}
|
|
@@ -170,5 +223,5 @@ export function createSeazonaGovernor({
|
|
|
170
223
|
};
|
|
171
224
|
}
|
|
172
225
|
|
|
173
|
-
return { clientKey: key, limitPerMinute: limit, minIntervalMs, reserve, recordResponse, status, summarise };
|
|
226
|
+
return { clientKey: key, limitPerMinute: limit, minIntervalMs, reserve, recordResponse, resume, status, summarise };
|
|
174
227
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { and, eq, inArray } from 'drizzle-orm';
|
|
2
|
+
import { FlowError } from '../../../core/errors.js';
|
|
3
|
+
import { mapOrder, parseSeazonaDate, text } from '../map.js';
|
|
4
|
+
import { seazonaHistoryBatchJobs, seazonaLaneState, seazonaOrders } from '../schema.js';
|
|
5
|
+
import { recordRecordFailure } from '../store.js';
|
|
6
|
+
import { createApiBatch, finishApiBatch, stageApiJobs } from './index.js';
|
|
7
|
+
|
|
8
|
+
// The history backfill (docs/SEAZONA.md, "History import"). A person starts
|
|
9
|
+
// it with a first day; the `history` lane then walks the orders from that day
|
|
10
|
+
// on with GET orders/?ordered=<day> and its pages, and reads each order's
|
|
11
|
+
// detail. It makes at most
|
|
12
|
+
// `historyCallsPerRun` calls a run, through the same rate governor and call
|
|
13
|
+
// guards as the live lanes, and keeps its place in the lane's cursor, so it
|
|
14
|
+
// resumes after a restart. Orders the live copy already holds are not read
|
|
15
|
+
// again. Each order becomes one job in an API batch: reconciled as it is
|
|
16
|
+
// read, written to Flow's history when it is new, and kept for a person when
|
|
17
|
+
// it is not. The batch rows are the record of what the API confirmed.
|
|
18
|
+
|
|
19
|
+
export const LANE = 'history';
|
|
20
|
+
const PAGE_SIZE = 500;
|
|
21
|
+
const DAY_MS = 24 * 60 * 60_000;
|
|
22
|
+
const RESOURCE = 'history_orders';
|
|
23
|
+
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
24
|
+
const orderedParam = (day) => `${day}T00:00:00`;
|
|
25
|
+
|
|
26
|
+
// An order's detail as a history job.
|
|
27
|
+
export function orderToHistoryJob(record) {
|
|
28
|
+
return {
|
|
29
|
+
invoiceNumber: record.invoiceNumber,
|
|
30
|
+
caseRef: record.id,
|
|
31
|
+
company: record.company,
|
|
32
|
+
accountNumber: null,
|
|
33
|
+
doctorName: record.doctorName,
|
|
34
|
+
status: record.status,
|
|
35
|
+
jobDate: null,
|
|
36
|
+
orderedDate: parseSeazonaDate(record.orderedText),
|
|
37
|
+
dueDate: parseSeazonaDate(record.dueText),
|
|
38
|
+
shippedDate: null,
|
|
39
|
+
lines: (record.lines ?? []).map((l) => ({
|
|
40
|
+
description: l.description,
|
|
41
|
+
code: l.code ?? null,
|
|
42
|
+
amountCents: l.amountCents ?? null,
|
|
43
|
+
...(typeof l.taxable === 'boolean' ? { taxable: l.taxable } : {}),
|
|
44
|
+
...(l.tooth ? { tooth: l.tooth } : {}),
|
|
45
|
+
})),
|
|
46
|
+
subtotalCents: record.subtotalCents,
|
|
47
|
+
totalCents: null,
|
|
48
|
+
patientReference: record.patientReference,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Starts a backfill from `from` (yyyy-mm-dd) through `through`, by default the
|
|
53
|
+
// day before the live recent-order lane's window. One runs at a time.
|
|
54
|
+
export async function startBackfill(db, { from, through = null, createdBy, settings, now = Date.now }) {
|
|
55
|
+
const at = new Date(Number(now()));
|
|
56
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(from ?? '')) || Number.isNaN(Date.parse(from))) {
|
|
57
|
+
throw Object.assign(new FlowError('invalid', 'from must be a day, yyyy-mm-dd'), { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
const last = through ?? dayOf(at.getTime() - (settings.sync.orderLookbackDays + 1) * DAY_MS);
|
|
60
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(last) || last < from) {
|
|
61
|
+
throw Object.assign(new FlowError('invalid', 'through must be a day on or after from'), { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
|
|
64
|
+
if (state?.cursor?.batchId && !state.cursor.done) {
|
|
65
|
+
throw Object.assign(new FlowError('conflict', 'A history backfill is already running'), { status: 409 });
|
|
66
|
+
}
|
|
67
|
+
const batch = await createApiBatch(db, { createdBy, from, through: last, at });
|
|
68
|
+
const cursor = { batchId: batch.id, from, through: last, nextDay: from, page: 1, pending: [], done: false, days: 0, read: 0 };
|
|
69
|
+
await db.insert(seazonaLaneState).values({ lane: LANE, status: 'idle', cursor, nextRunAt: at })
|
|
70
|
+
.onConflictDoUpdate({ target: seazonaLaneState.lane, set: { status: 'idle', cursor, nextRunAt: at, failureCount: 0, lastError: null, heldReason: null, updatedAt: at } });
|
|
71
|
+
return describeBackfill(cursor);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Stops a running backfill. Its batch keeps what it read and waits for a person.
|
|
75
|
+
export async function stopBackfill(db, { now = Date.now } = {}) {
|
|
76
|
+
const at = new Date(Number(now()));
|
|
77
|
+
const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
|
|
78
|
+
const cursor = state?.cursor;
|
|
79
|
+
if (!cursor?.batchId || cursor.done) throw Object.assign(new FlowError('conflict', 'No history backfill is running'), { status: 409 });
|
|
80
|
+
const stopped = { ...cursor, done: true, stopped: true };
|
|
81
|
+
await db.update(seazonaLaneState).set({ cursor: stopped, updatedAt: at }).where(eq(seazonaLaneState.lane, LANE));
|
|
82
|
+
await finishApiBatch(db, cursor.batchId, { at });
|
|
83
|
+
return describeBackfill(stopped);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function describeBackfill(cursor) {
|
|
87
|
+
if (!cursor?.batchId) return null;
|
|
88
|
+
return {
|
|
89
|
+
batchId: cursor.batchId,
|
|
90
|
+
from: cursor.from,
|
|
91
|
+
through: cursor.through,
|
|
92
|
+
nextDay: cursor.done ? null : cursor.nextDay,
|
|
93
|
+
daysDone: cursor.days ?? 0,
|
|
94
|
+
reachedDay: cursor.reached ?? null,
|
|
95
|
+
ordersRead: cursor.read ?? 0,
|
|
96
|
+
waitingToRead: cursor.pending?.length ?? 0,
|
|
97
|
+
running: !cursor.done,
|
|
98
|
+
stopped: Boolean(cursor.stopped),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readBackfill(db) {
|
|
103
|
+
const [state] = await db.select().from(seazonaLaneState).where(eq(seazonaLaneState.lane, LANE));
|
|
104
|
+
return describeBackfill(state?.cursor);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// One run of the `history` lane. `helpers` are the lane module's failure
|
|
108
|
+
// rules, so the backfill fails, holds and stops exactly as the live lanes do.
|
|
109
|
+
export async function runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }) {
|
|
110
|
+
const { db, client, sync, at, state } = ctx;
|
|
111
|
+
const start = state?.cursor ?? {};
|
|
112
|
+
if (!start.batchId || start.done) return { idle: true, cursor: start, cursorTime: state?.cursorTime ?? null };
|
|
113
|
+
const cursor = { ...start, pending: [...(start.pending ?? [])] };
|
|
114
|
+
const budget = sync.historyCallsPerRun;
|
|
115
|
+
const pageSize = sync.historyPageSize ?? PAGE_SIZE;
|
|
116
|
+
const jobs = [];
|
|
117
|
+
const failures = [];
|
|
118
|
+
let calls = 0;
|
|
119
|
+
let deleted = 0;
|
|
120
|
+
let quarantined = 0;
|
|
121
|
+
|
|
122
|
+
const inBatch = async (ids) => {
|
|
123
|
+
if (!ids.length) return new Set();
|
|
124
|
+
const rows = await db.select({ key: seazonaHistoryBatchJobs.jobKey }).from(seazonaHistoryBatchJobs)
|
|
125
|
+
.where(and(eq(seazonaHistoryBatchJobs.batchId, cursor.batchId), inArray(seazonaHistoryBatchJobs.jobKey, ids.map((id) => `case:${id}`))));
|
|
126
|
+
return new Set(rows.map((r) => r.key.slice(5)));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
while (calls < budget) {
|
|
131
|
+
if (!cursor.pending.length) {
|
|
132
|
+
if (cursor.nextDay > cursor.through) {
|
|
133
|
+
cursor.done = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
calls += 1;
|
|
137
|
+
const list = await client.get('orders/', { ordered: orderedParam(cursor.nextDay), page: cursor.page, pageSize });
|
|
138
|
+
if (!Array.isArray(list)) throw new LaneError(`Seazona order list for ${cursor.nextDay} answer was not a list`, { code: 'seazona_list_invalid' });
|
|
139
|
+
// The guide says the list answers orders placed on or after `ordered`,
|
|
140
|
+
// oldest first, in stable pages; the live lane was built when it
|
|
141
|
+
// answered one day. Both work here: orders after `through` end the walk,
|
|
142
|
+
// a full page goes on to the next page, and a short page to the next day.
|
|
143
|
+
const placed = (item) => (typeof item?.ordered === 'string' ? item.ordered.slice(0, 10) : null);
|
|
144
|
+
const inRange = list.filter((item) => !(placed(item) > cursor.through));
|
|
145
|
+
const beyond = inRange.length < list.length;
|
|
146
|
+
for (const item of inRange) if (placed(item) > (cursor.reached ?? '')) cursor.reached = placed(item);
|
|
147
|
+
const ids = [...new Set(inRange.map((item) => text(item?.id)).filter(Boolean))];
|
|
148
|
+
// The live copy holds these already; a read again costs a call for nothing.
|
|
149
|
+
const live = ids.length
|
|
150
|
+
? new Set((await db.select({ id: seazonaOrders.id }).from(seazonaOrders).where(inArray(seazonaOrders.id, ids))).map((r) => r.id))
|
|
151
|
+
: new Set();
|
|
152
|
+
const seen = await inBatch(ids);
|
|
153
|
+
cursor.pending = ids.filter((id) => !live.has(id) && !seen.has(id));
|
|
154
|
+
if (beyond) {
|
|
155
|
+
cursor.nextDay = dayOf(Date.parse(cursor.through) + DAY_MS);
|
|
156
|
+
} else if (list.length >= pageSize) {
|
|
157
|
+
cursor.page += 1;
|
|
158
|
+
} else {
|
|
159
|
+
cursor.page = 1;
|
|
160
|
+
cursor.nextDay = dayOf(Date.parse(cursor.nextDay) + DAY_MS);
|
|
161
|
+
cursor.days = (cursor.days ?? 0) + 1;
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const id = cursor.pending.shift();
|
|
166
|
+
if ((await inBatch([id])).size) continue;
|
|
167
|
+
calls += 1;
|
|
168
|
+
try {
|
|
169
|
+
const detail = await client.get(`orders/${encodeURIComponent(id)}`);
|
|
170
|
+
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`order ${id}`);
|
|
171
|
+
const record = { ...mapOrder({}, detail, { keepPatientReference: sync.keepPatientReference }), id };
|
|
172
|
+
jobs.push({ jobKey: `case:${id}`, job: orderToHistoryJob(record) });
|
|
173
|
+
cursor.read = (cursor.read ?? 0) + 1;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (isLaneLevel(error)) throw error;
|
|
176
|
+
// Seazona has no such order now: nothing to keep.
|
|
177
|
+
if (error?.statusCode === 404) {
|
|
178
|
+
deleted += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const failure = describeReadFailure(error);
|
|
182
|
+
const recorded = await recordRecordFailure(db, RESOURCE, id, failure, {
|
|
183
|
+
at, quarantineFailures: sync.orderQuarantineFailures, quarantineMs: sync.orderQuarantineMs,
|
|
184
|
+
});
|
|
185
|
+
failures.push({ id, ...failure, ...recorded });
|
|
186
|
+
// Read again later in the walk, until it is quarantined.
|
|
187
|
+
if (recorded.quarantinedUntil) quarantined += 1;
|
|
188
|
+
else cursor.pending.push(id);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
// What was read is kept even when the lane stops on a lane-level failure.
|
|
193
|
+
await stageApiJobs(db, cursor.batchId, jobs, { at });
|
|
194
|
+
}
|
|
195
|
+
if (cursor.done) await finishApiBatch(db, cursor.batchId, { at });
|
|
196
|
+
const failureDetails = failures.length
|
|
197
|
+
? { attempted: calls, failed: failures.length, failures: failures.slice(0, 25).map(({ error, ...f }) => f), quarantined }
|
|
198
|
+
: null;
|
|
199
|
+
return {
|
|
200
|
+
calls,
|
|
201
|
+
read: jobs.length,
|
|
202
|
+
deleted,
|
|
203
|
+
backfill: describeBackfill(cursor),
|
|
204
|
+
failureDetails,
|
|
205
|
+
cursor,
|
|
206
|
+
cursorTime: at,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A small RFC 4180 reader for Seazona's CSV export: quoted fields, doubled
|
|
2
|
+
// quotes, commas and line breaks inside quotes, CRLF or LF, and a leading
|
|
3
|
+
// byte order mark. Answers { headers, rows }, each row an array of strings.
|
|
4
|
+
// Blank lines are skipped. It never evaluates a cell.
|
|
5
|
+
|
|
6
|
+
export class CsvError extends Error {
|
|
7
|
+
constructor(message, line) {
|
|
8
|
+
super(line ? `${message} (line ${line})` : message);
|
|
9
|
+
this.code = 'csv_invalid';
|
|
10
|
+
this.line = line ?? null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseCsv(input) {
|
|
15
|
+
const text = String(input ?? '').replace(/^/, '');
|
|
16
|
+
const records = [];
|
|
17
|
+
let record = [];
|
|
18
|
+
let field = '';
|
|
19
|
+
let quoted = false;
|
|
20
|
+
let line = 1;
|
|
21
|
+
let startLine = 1;
|
|
22
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
23
|
+
const ch = text[i];
|
|
24
|
+
if (quoted) {
|
|
25
|
+
if (ch === '"') {
|
|
26
|
+
if (text[i + 1] === '"') {
|
|
27
|
+
field += '"';
|
|
28
|
+
i += 1;
|
|
29
|
+
} else {
|
|
30
|
+
quoted = false;
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
if (ch === '\n') line += 1;
|
|
34
|
+
field += ch;
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (ch === '"' && field === '') {
|
|
39
|
+
quoted = true;
|
|
40
|
+
} else if (ch === ',') {
|
|
41
|
+
record.push(field);
|
|
42
|
+
field = '';
|
|
43
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
44
|
+
if (ch === '\r' && text[i + 1] === '\n') i += 1;
|
|
45
|
+
record.push(field);
|
|
46
|
+
records.push({ cells: record, line: startLine });
|
|
47
|
+
record = [];
|
|
48
|
+
field = '';
|
|
49
|
+
line += 1;
|
|
50
|
+
startLine = line;
|
|
51
|
+
} else {
|
|
52
|
+
field += ch;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (quoted) throw new CsvError('A quoted field has no closing quote', startLine);
|
|
56
|
+
if (field !== '' || record.length) {
|
|
57
|
+
record.push(field);
|
|
58
|
+
records.push({ cells: record, line: startLine });
|
|
59
|
+
}
|
|
60
|
+
const nonBlank = records.filter((r) => r.cells.some((cell) => cell.trim() !== ''));
|
|
61
|
+
if (!nonBlank.length) throw new CsvError('The file has no header row');
|
|
62
|
+
const [head, ...body] = nonBlank;
|
|
63
|
+
const headers = head.cells.map((h) => h.trim());
|
|
64
|
+
return { headers, rows: body.map((r) => ({ line: r.line, cells: r.cells })) };
|
|
65
|
+
}
|