@arcforgelabs/flow 2026.10.18 → 2026.10.19-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 (65) hide show
  1. package/README.md +3 -0
  2. package/SECURITY.md +4 -1
  3. package/npm-shrinkwrap.json +32 -3
  4. package/package.json +3 -2
  5. package/server/app.js +26 -3
  6. package/server/auth/routes.js +27 -12
  7. package/server/auth/session.js +6 -3
  8. package/server/auth/settings.js +26 -7
  9. package/server/auth/sign-in.html +17 -6
  10. package/server/core/invoices.js +6 -3
  11. package/server/core/ledger.js +420 -0
  12. package/server/db/migrations/0003_billing_ledger.sql +84 -0
  13. package/server/db/migrations/0004_ledger_events_append_only.sql +5 -0
  14. package/server/db/migrations/meta/0003_snapshot.json +1327 -0
  15. package/server/db/migrations/meta/0004_snapshot.json +1327 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +137 -0
  18. package/server/import/earlier-flow.js +230 -0
  19. package/server/import-earlier-flow.js +66 -0
  20. package/server/index.js +16 -4
  21. package/server/integrations/adapters.js +6 -0
  22. package/server/integrations/health.js +21 -0
  23. package/server/integrations/index.js +26 -0
  24. package/server/integrations/port.js +154 -6
  25. package/server/integrations/remove-tables.js +31 -0
  26. package/server/integrations/schema.js +62 -0
  27. package/server/integrations/seazona/client.js +196 -0
  28. package/server/integrations/seazona/drizzle.config.js +9 -0
  29. package/server/integrations/seazona/feed.js +104 -0
  30. package/server/integrations/seazona/governor.js +174 -0
  31. package/server/integrations/seazona/index.js +84 -0
  32. package/server/integrations/seazona/lanes.js +431 -0
  33. package/server/integrations/seazona/ledger.js +66 -0
  34. package/server/integrations/seazona/map.js +229 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +20 -0
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +106 -0
  37. package/server/integrations/seazona/migrations/meta/0000_snapshot.json +149 -0
  38. package/server/integrations/seazona/migrations/meta/0001_snapshot.json +793 -0
  39. package/server/integrations/seazona/migrations/meta/_journal.json +20 -0
  40. package/server/integrations/seazona/replay.js +79 -0
  41. package/server/integrations/seazona/scheduler.js +78 -0
  42. package/server/integrations/seazona/schema.js +157 -0
  43. package/server/integrations/seazona/settings.js +161 -0
  44. package/server/integrations/seazona/store.js +219 -0
  45. package/server/integrations/seazona/sync-once.js +44 -0
  46. package/server/integrations/seazona/tables.js +17 -0
  47. package/server/integrations/xero/catalogue.js +184 -0
  48. package/server/integrations/xero/client.js +253 -0
  49. package/server/integrations/xero/connections.js +104 -0
  50. package/server/integrations/xero/drafts.js +525 -0
  51. package/server/integrations/xero/fixtures/replay.json +45 -0
  52. package/server/integrations/xero/governor.js +275 -0
  53. package/server/integrations/xero/index.js +56 -0
  54. package/server/integrations/xero/ledger.js +269 -0
  55. package/server/integrations/xero/migrations/0000_xero_connections.sql +17 -0
  56. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +49 -0
  57. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +32 -0
  58. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +7 -0
  59. package/server/integrations/xero/migrations/meta/_journal.json +34 -0
  60. package/server/integrations/xero/oauth.js +143 -0
  61. package/server/integrations/xero/replay.js +83 -0
  62. package/server/integrations/xero/routes.js +234 -0
  63. package/server/integrations/xero/runtime.js +135 -0
  64. package/server/integrations/xero/settings.js +170 -0
  65. package/server/rate-limit.js +13 -19
@@ -0,0 +1,174 @@
1
+ import { and, count, desc, eq, gte, lt, sql } from 'drizzle-orm';
2
+ import { seazonaApiGovernor, seazonaApiRequests } from './schema.js';
3
+ import { MAX_REQUESTS_PER_MINUTE } from './settings.js';
4
+
5
+ // The Seazona rate governor. Seazona sees every process that holds the same
6
+ // API client as one client, so the limit is kept in PostgreSQL, not in
7
+ // memory: a workspace and a deployment on one database share it.
8
+ //
9
+ // A request is admitted when fewer than `requestsPerMinute` requests were
10
+ // admitted in the last minute, the minimum gap since the last admission has
11
+ // passed, and no 429 cooldown is running. Admission takes a transaction-level
12
+ // advisory lock per client, so two processes never admit the same slot.
13
+
14
+ const WINDOW_MS = 60_000;
15
+ const MAX_COOLDOWN_MS = 60_000;
16
+
17
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
18
+
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
+ function normaliseWorkflow(value) {
26
+ return String(value || 'seazona').trim().toLowerCase().replace(/[^a-z0-9:_-]+/g, '-').slice(0, 80) || 'seazona';
27
+ }
28
+
29
+ export class SeazonaGovernorTimeout extends Error {
30
+ constructor(waitedMs, retryAfterMs) {
31
+ super(`Seazona request waited ${waitedMs} ms for a free slot and gave up`);
32
+ this.name = 'SeazonaGovernorTimeout';
33
+ this.code = 'seazona_api_governor_timeout';
34
+ this.kind = 'governor_timeout';
35
+ this.retryAfterMs = retryAfterMs;
36
+ }
37
+ }
38
+
39
+ export function createSeazonaGovernor({
40
+ db,
41
+ clientKey,
42
+ requestsPerMinute = MAX_REQUESTS_PER_MINUTE,
43
+ maxWaitMs = 120_000,
44
+ telemetryRetentionMs = 35 * 24 * 60 * 60_000,
45
+ now = Date.now,
46
+ sleepFn = sleep,
47
+ } = {}) {
48
+ if (!db) throw new TypeError('db is required');
49
+ const key = String(clientKey || '').trim().slice(0, 120);
50
+ if (!key) throw new TypeError('clientKey is required');
51
+ const limit = Math.min(MAX_REQUESTS_PER_MINUTE, Math.max(1, Math.trunc(requestsPerMinute) || MAX_REQUESTS_PER_MINUTE));
52
+ const minIntervalMs = Math.ceil(WINDOW_MS / limit);
53
+ const lockKey = `seazona-governor:${key}`;
54
+
55
+ async function tryAdmit(workflow, isRetry) {
56
+ return db.transaction(async (tx) => {
57
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`);
58
+ const nowMs = Number(now());
59
+ const nowAt = new Date(nowMs);
60
+ await tx.insert(seazonaApiGovernor).values({ clientKey: key }).onConflictDoNothing();
61
+ const [state] = await tx.select().from(seazonaApiGovernor).where(eq(seazonaApiGovernor.clientKey, key));
62
+ const windowStart = new Date(nowMs - WINDOW_MS);
63
+ const [{ admitted, oldestMs }] = await tx
64
+ .select({ admitted: count(), oldestMs: sql`(extract(epoch from min(${seazonaApiRequests.admittedAt})) * 1000)::float8` })
65
+ .from(seazonaApiRequests)
66
+ .where(and(eq(seazonaApiRequests.clientKey, key), gte(seazonaApiRequests.admittedAt, windowStart)));
67
+
68
+ let eligibleAt = Math.max(nowMs, state.nextEligibleAt.getTime(), state.cooldownUntil.getTime());
69
+ if (Number(admitted) >= limit && oldestMs !== null) {
70
+ eligibleAt = Math.max(eligibleAt, Math.ceil(Number(oldestMs)) + WINDOW_MS + 1);
71
+ }
72
+ if (eligibleAt > nowMs) return { admitted: false, waitMs: eligibleAt - nowMs };
73
+
74
+ const [row] = await tx.insert(seazonaApiRequests).values({
75
+ clientKey: key,
76
+ workflow: normaliseWorkflow(workflow),
77
+ admittedAt: nowAt,
78
+ isRetry: Boolean(isRetry),
79
+ }).returning({ id: seazonaApiRequests.id });
80
+ await tx.update(seazonaApiGovernor)
81
+ .set({ nextEligibleAt: new Date(nowMs + minIntervalMs), updatedAt: nowAt })
82
+ .where(eq(seazonaApiGovernor.clientKey, key));
83
+ return { admitted: true, requestId: row.id };
84
+ });
85
+ }
86
+
87
+ // Waits for a slot and records the admission. Returns { requestId }.
88
+ async function reserve({ workflow = 'seazona', isRetry = false } = {}) {
89
+ let waitedMs = 0;
90
+ for (;;) {
91
+ const decision = await tryAdmit(workflow, isRetry);
92
+ if (decision.admitted) return { requestId: decision.requestId, waitedMs };
93
+ const delay = Math.max(1, Math.min(decision.waitMs, maxWaitMs));
94
+ if (waitedMs + delay > maxWaitMs) throw new SeazonaGovernorTimeout(waitedMs, delay);
95
+ waitedMs += delay;
96
+ await sleepFn(delay);
97
+ }
98
+ }
99
+
100
+ // Records how a request ended, and any cooldown a 429 asked for.
101
+ 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
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
110
+ const until = new Date(nowMs + Math.min(retryAfterMs, MAX_COOLDOWN_MS));
111
+ await db.update(seazonaApiGovernor)
112
+ .set({ cooldownUntil: sql`greatest(${seazonaApiGovernor.cooldownUntil}, ${until})`, updatedAt: new Date(nowMs) })
113
+ .where(eq(seazonaApiGovernor.clientKey, key));
114
+ }
115
+ await db.delete(seazonaApiRequests).where(and(
116
+ eq(seazonaApiRequests.clientKey, key),
117
+ lt(seazonaApiRequests.admittedAt, new Date(nowMs - telemetryRetentionMs)),
118
+ ));
119
+ }
120
+
121
+ // Counts for /health: the last minute, and the last day by status.
122
+ async function status() {
123
+ const nowMs = Number(now());
124
+ const [state] = await db.select().from(seazonaApiGovernor).where(eq(seazonaApiGovernor.clientKey, key));
125
+ const [{ lastMinute }] = await db.select({ lastMinute: count() }).from(seazonaApiRequests)
126
+ .where(and(eq(seazonaApiRequests.clientKey, key), gte(seazonaApiRequests.admittedAt, new Date(nowMs - WINDOW_MS))));
127
+ const day = await summarise({ from: new Date(nowMs - 24 * 60 * 60_000), to: new Date(nowMs) });
128
+ const cooldownUntil = state && state.cooldownUntil.getTime() > nowMs ? state.cooldownUntil.toISOString() : null;
129
+ return {
130
+ limitPerMinute: limit,
131
+ minIntervalMs,
132
+ lastMinute: Number(lastMinute),
133
+ cooldownUntil,
134
+ lastDay: { requests: day.requests, failed: day.failed, rateLimited: day.rateLimited, retries: day.retries },
135
+ };
136
+ }
137
+
138
+ // What was sent between two times: the totals, and each path that failed.
139
+ // This answers "how many reads failed last month, and which ones?".
140
+ async function summarise({ from, to, topFailures = 50 } = {}) {
141
+ const range = and(
142
+ eq(seazonaApiRequests.clientKey, key),
143
+ gte(seazonaApiRequests.admittedAt, from),
144
+ lt(seazonaApiRequests.admittedAt, to),
145
+ );
146
+ const [totals] = await db.select({
147
+ requests: count(),
148
+ failed: sql`count(*) filter (where ${seazonaApiRequests.statusCode} is null or ${seazonaApiRequests.statusCode} >= 400)`,
149
+ rateLimited: sql`count(*) filter (where ${seazonaApiRequests.statusCode} = 429)`,
150
+ retries: sql`count(*) filter (where ${seazonaApiRequests.isRetry})`,
151
+ }).from(seazonaApiRequests).where(range);
152
+ const failures = await db.select({
153
+ path: seazonaApiRequests.requestPath,
154
+ statusCode: seazonaApiRequests.statusCode,
155
+ failureKind: seazonaApiRequests.failureKind,
156
+ requests: count(),
157
+ }).from(seazonaApiRequests)
158
+ .where(and(range, sql`(${seazonaApiRequests.statusCode} is null or ${seazonaApiRequests.statusCode} >= 400)`))
159
+ .groupBy(seazonaApiRequests.requestPath, seazonaApiRequests.statusCode, seazonaApiRequests.failureKind)
160
+ .orderBy(desc(count()))
161
+ .limit(topFailures);
162
+ return {
163
+ from: from.toISOString(),
164
+ to: to.toISOString(),
165
+ requests: Number(totals.requests),
166
+ failed: Number(totals.failed),
167
+ rateLimited: Number(totals.rateLimited),
168
+ retries: Number(totals.retries),
169
+ failures: failures.map((row) => ({ ...row, requests: Number(row.requests) })),
170
+ };
171
+ }
172
+
173
+ return { clientKey: key, limitPerMinute: limit, minIntervalMs, reserve, recordResponse, status, summarise };
174
+ }
@@ -0,0 +1,84 @@
1
+ import { createSeazonaClient } from './client.js';
2
+ import { readBillableWork } from './feed.js';
3
+ import { readSeazonaSyncHealth, runSeazonaSync } from './lanes.js';
4
+ import { createSeazonaScheduler } from './scheduler.js';
5
+ import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
6
+ import { seazonaSchema } from './tables.js';
7
+
8
+ // The Seazona adapter (docs/SEAZONA.md). FLOW_INTEGRATIONS=seazona turns it
9
+ // on and SEAZONA_MODE says which Seazona it acts on. Its tables are created
10
+ // only while it is on (docs/INTEGRATIONS.md). Core code never imports this
11
+ // file; it reaches the adapter through the registry.
12
+
13
+ // Seazona on with no mode, or a malformed setting, stops boot: the adapter
14
+ // never guesses which account it acts on (issue #10).
15
+ export function requireSeazonaSettings(env = process.env) {
16
+ const settings = resolveSeazonaSettings(env);
17
+ if (!settings.mode) {
18
+ throw new Error('FLOW_INTEGRATIONS turns Seazona on, so SEAZONA_MODE must be "live", "sandbox" or "replay" (docs/SEAZONA.md)');
19
+ }
20
+ return settings;
21
+ }
22
+
23
+ // `env`, `fetchImpl`, `now` and `timers` are for tests. Otherwise start()
24
+ // reads the environment, so a turned-off adapter never reads its settings.
25
+ export function createSeazonaAdapter({ env = process.env, fetchImpl = null, now = Date.now, timers } = {}) {
26
+ let runtime = null; // { settings, db, client, scheduler }
27
+
28
+ function current() {
29
+ if (!runtime) throw new Error('The Seazona adapter has not started');
30
+ return runtime;
31
+ }
32
+
33
+ return {
34
+ id: 'seazona',
35
+ name: 'Seazona',
36
+ // VISION.md: Seazona is bundled and on in the dental preset.
37
+ bundled: true,
38
+ schema: seazonaSchema,
39
+
40
+ async start({ db, log = console }) {
41
+ const settings = requireSeazonaSettings(env);
42
+ const client = createSeazonaClient({ settings, db, fetchImpl: fetchImpl ?? undefined, workflow: 'seazona-sync', now });
43
+ const run = (options = {}) => runSeazonaSync(db, client, { settings, now, ...options });
44
+ // The background sync starts in live mode only. A sandbox or replay
45
+ // installation syncs only when someone asks (sync-once.js).
46
+ const scheduler = settings.scheduled ? createSeazonaScheduler({ db, settings, run, now, log, timers }) : null;
47
+ runtime = { settings, db, client, run, scheduler };
48
+ scheduler?.start();
49
+ log.info?.(`[seazona] mode ${settings.mode}; background sync ${scheduler ? 'on' : 'off (live mode only)'}; ${settings.rate.requestsPerMinute} requests a minute at most`);
50
+ },
51
+
52
+ async stop() {
53
+ await runtime?.scheduler?.stop();
54
+ },
55
+
56
+ capabilities: {
57
+ // Connection state for /health and Settings: the mode, the rate
58
+ // governor, and each lane with its failing and quarantined orders.
59
+ // It reads Flow's database only; it never calls Seazona.
60
+ async health() {
61
+ if (!runtime) return { ok: false, reason: 'not_started', ...describeSeazonaSettings(null) };
62
+ const { settings, db, client, scheduler } = runtime;
63
+ const sync = await readSeazonaSyncHealth(db, settings, { now });
64
+ return {
65
+ ok: sync.ok,
66
+ ...describeSeazonaSettings(settings),
67
+ scheduler: scheduler ? scheduler.status() : null,
68
+ governor: await client.governor.status(),
69
+ ...sync,
70
+ };
71
+ },
72
+
73
+ // Billable work since a cursor: complete and shipped orders that
74
+ // changed, with the Seazona invoice that carries each one. Orders
75
+ // deleted in Seazona are never billable; they come back in `withdrawn`
76
+ // so invoice preparation can drop them (issue #12).
77
+ async pullWorkItems(cursor = null, options = {}) {
78
+ return readBillableWork(current().db, { cursor, ...options });
79
+ },
80
+ },
81
+ };
82
+ }
83
+
84
+ export const seazonaAdapter = createSeazonaAdapter();
@@ -0,0 +1,431 @@
1
+ import { count, isNotNull } from 'drizzle-orm';
2
+ import {
3
+ clientListSignature,
4
+ invoiceListSignature,
5
+ mapClient,
6
+ mapInvoice,
7
+ mapOrder,
8
+ mapReference,
9
+ orderListSignature,
10
+ text,
11
+ } from './map.js';
12
+ import { seazonaOrders } from './schema.js';
13
+ import {
14
+ claimLane,
15
+ clearOrderFailure,
16
+ finishLane,
17
+ markOrderDeleted,
18
+ readKnown,
19
+ readLaneStates,
20
+ readQuarantinedOrders,
21
+ recordOrderFailure,
22
+ selectActiveOrders,
23
+ touchSynced,
24
+ upsertRecords,
25
+ upsertReference,
26
+ } from './store.js';
27
+
28
+ // The Seazona sync lanes. Each lane keeps one kind of record fresh, on its
29
+ // own cadence, and one failing lane never stops another:
30
+ //
31
+ // invoices changed invoices since a lastModified cursor, every 2 min
32
+ // orders_recent orders placed in the last 30 days, a few days per run
33
+ // orders_active open orders already known, re-read oldest first
34
+ // clients changed clients since a cursor, every 30 min
35
+ // products the product list, daily
36
+ // users the lab user list, daily
37
+ //
38
+ // Issue #12 holds for every order read: a 404 marks the order deleted in
39
+ // Seazona, an empty answer is a failure, each failure is recorded per order
40
+ // and quarantines it after a few in a row, and a lane fails only when most of
41
+ // its reads fail. Errors and /health name each failing order and its status.
42
+
43
+ export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users']);
44
+
45
+ const DAY_MS = 24 * 60 * 60_000;
46
+ const MAX_REPORTED = 25;
47
+ const FINAL_STATUS_PATTERN = 'shipped|delivered|cancelled|canceled|void|closed|archived|deleted';
48
+
49
+ class LaneError extends Error {
50
+ constructor(message, { code, details = null, held = false, retryAfterMs = null } = {}) {
51
+ super(message);
52
+ this.code = code;
53
+ this.details = details;
54
+ this.held = held;
55
+ this.retryAfterMs = retryAfterMs;
56
+ }
57
+ }
58
+
59
+ function listOrFail(list, what, sync) {
60
+ 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
+ return list;
71
+ }
72
+
73
+ // A failure that is about Seazona or the rate limit, not one record: the
74
+ // whole lane stops and waits.
75
+ function isLaneLevel(error) {
76
+ return error?.statusCode === 429 || error?.kind === 'governor_timeout';
77
+ }
78
+
79
+ function describeReadFailure(error) {
80
+ const message = text(error?.message) || 'read failed';
81
+ if (error?.code === 'seazona_empty_response') return { httpStatus: null, kind: 'empty_response', error: message };
82
+ if (Number.isInteger(error?.statusCode)) return { httpStatus: error.statusCode, kind: 'http', error: message };
83
+ return { httpStatus: null, kind: error?.kind || 'error', error: message };
84
+ }
85
+
86
+ function emptyResponse(what) {
87
+ const error = new Error(`Seazona ${what} answered with an empty body`);
88
+ error.code = 'seazona_empty_response';
89
+ return error;
90
+ }
91
+
92
+ const failureLabel = (failure) => `${failure.id} (${failure.httpStatus ? `HTTP ${failure.httpStatus}` : failure.kind})`;
93
+
94
+ // Issue #12: a lane fails only when most of its reads fail, which points at
95
+ // Seazona or the credentials rather than single records. Otherwise it
96
+ // succeeds and reports the failures.
97
+ function judgeReads(what, attempted, failures, extra = {}) {
98
+ const reported = failures.slice(0, MAX_REPORTED).map(({ error, ...failure }) => failure);
99
+ const details = failures.length || extra.quarantinedIds?.length
100
+ ? { attempted, failed: failures.length, failures: reported, ...extra }
101
+ : null;
102
+ if (failures.length && failures.length * 2 > attempted) {
103
+ const labels = failures.slice(0, 10).map(failureLabel).join(', ');
104
+ const more = failures.length > 10 ? `, and ${failures.length - 10} more` : '';
105
+ throw new LaneError(`Seazona ${what} failed for ${failures.length} of ${attempted}: ${labels}${more}`, {
106
+ code: 'seazona_reads_failed',
107
+ details,
108
+ });
109
+ }
110
+ return details;
111
+ }
112
+
113
+ // Reads one order's detail and applies issue #12. Returns
114
+ // { outcome: 'read' | 'deleted' | 'failed', failure? }.
115
+ async function readOrder(ctx, id, { listItem = null, known = null } = {}) {
116
+ const { db, client, sync, at } = ctx;
117
+ const mapOptions = { keepPatientReference: sync.keepPatientReference };
118
+ try {
119
+ const detail = await client.get(`orders/${encodeURIComponent(id)}`);
120
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`order ${id}`);
121
+ const record = { ...mapOrder(listItem || {}, detail, mapOptions), id };
122
+ const listSignatures = listItem ? new Map([[id, orderListSignature(listItem)]]) : new Map();
123
+ const summary = await upsertRecords(db, 'orders', [record], { at, listSignatures });
124
+ await clearOrderFailure(db, id);
125
+ return { outcome: 'read', summary };
126
+ } catch (error) {
127
+ if (isLaneLevel(error)) throw error;
128
+ if (error?.statusCode === 404) {
129
+ const fallback = listItem ? { ...mapOrder(listItem, null, mapOptions), id } : null;
130
+ await markOrderDeleted(db, id, { at, fallback });
131
+ await clearOrderFailure(db, id);
132
+ return { outcome: 'deleted' };
133
+ }
134
+ // An order seen in a list but never stored is kept from its list row, so
135
+ // the active-order lane picks it up again.
136
+ if (listItem && !known) {
137
+ await upsertRecords(db, 'orders', [{ ...mapOrder(listItem, null, mapOptions), id }], { at });
138
+ }
139
+ const failure = describeReadFailure(error);
140
+ const recorded = await recordOrderFailure(db, id, failure, {
141
+ at,
142
+ quarantineFailures: sync.orderQuarantineFailures,
143
+ quarantineMs: sync.orderQuarantineMs,
144
+ });
145
+ return { outcome: 'failed', failure: { id, ...failure, ...recorded } };
146
+ }
147
+ }
148
+
149
+ function addSummary(total, summary) {
150
+ if (!summary) return;
151
+ total.created += summary.created;
152
+ total.changed += summary.changed;
153
+ total.unchanged += summary.unchanged;
154
+ }
155
+
156
+ // Invoices and clients: a list since a cursor, then a detail read for each
157
+ // row whose list fields changed.
158
+ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signatureOf, detailPath, map }) {
159
+ const { db, client, at } = ctx;
160
+ const keyed = items.map((item) => ({ item, key: keyOf(item) })).filter((entry) => entry.key);
161
+ const known = await readKnown(db, mirror, keyed.map((entry) => entry.key));
162
+ const summary = { created: 0, changed: 0, unchanged: 0 };
163
+ const unchanged = [];
164
+ const failures = [];
165
+ let detailReads = 0;
166
+ for (const { item, key } of keyed) {
167
+ const signature = signatureOf(item);
168
+ if (known.get(key)?.listSignature === signature) {
169
+ unchanged.push(key);
170
+ continue;
171
+ }
172
+ const id = idOf(item);
173
+ let detail = null;
174
+ if (id) {
175
+ try {
176
+ detailReads += 1;
177
+ detail = await client.get(detailPath(id));
178
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${key}`);
179
+ } catch (error) {
180
+ if (isLaneLevel(error)) throw error;
181
+ failures.push({ id: key, ...describeReadFailure(error) });
182
+ continue;
183
+ }
184
+ }
185
+ addSummary(summary, await upsertRecords(db, mirror, [map(item, detail)], { at, listSignatures: new Map([[key, signature]]) }));
186
+ }
187
+ await touchSynced(db, mirror, unchanged, { at });
188
+ summary.unchanged += unchanged.length;
189
+ const failureDetails = judgeReads(`${what} reads`, detailReads, failures);
190
+ return { summary, detailReads, listCount: items.length, failureDetails, anyFailed: failures.length > 0 };
191
+ }
192
+
193
+ function cursorSince(ctx, fallbackMs) {
194
+ const cursorMs = ctx.state?.cursorTime?.getTime();
195
+ return new Date(Number.isFinite(cursorMs) ? cursorMs - ctx.sync.cursorOverlapMs : fallbackMs).toISOString();
196
+ }
197
+
198
+ const floorUtcDay = (ms) => {
199
+ const date = new Date(ms);
200
+ return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
201
+ };
202
+
203
+ // GET orders/?ordered= matches one UTC calendar day, written
204
+ // yyyy-MM-ddTHH:mm:ss without milliseconds.
205
+ export function formatOrderedDay(ms) {
206
+ return new Date(floorUtcDay(ms)).toISOString().replace(/\.\d{3}Z$/, '');
207
+ }
208
+
209
+ const laneRuns = {
210
+ async invoices(ctx) {
211
+ 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);
214
+ const mapOptions = { keepPatientReference: sync.keepPatientReference };
215
+ const result = await syncListWithDetail(ctx, {
216
+ mirror: 'invoices',
217
+ 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),
224
+ });
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
+ },
228
+
229
+ async orders_recent(ctx) {
230
+ const { db, client, sync, at } = ctx;
231
+ const nowMs = at.getTime();
232
+ const lookbackStart = floorUtcDay(nowMs - sync.orderLookbackDays * DAY_MS);
233
+ 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);
238
+
239
+ 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);
243
+ }
244
+ const known = await readKnown(db, 'orders', [...byId.keys()]);
245
+ const summary = { created: 0, changed: 0, unchanged: 0 };
246
+ const unchanged = [];
247
+ const failures = [];
248
+ const deleted = [];
249
+ let detailReads = 0;
250
+ for (const [id, item] of byId) {
251
+ const stored = known.get(id);
252
+ // An order marked deleted that shows up in a list is read again.
253
+ if (stored && !stored.deletedInSourceAt && stored.listSignature === orderListSignature(item)) {
254
+ unchanged.push(id);
255
+ continue;
256
+ }
257
+ detailReads += 1;
258
+ const result = await readOrder(ctx, id, { listItem: item, known: stored });
259
+ if (result.outcome === 'read') addSummary(summary, result.summary);
260
+ else if (result.outcome === 'deleted') deleted.push(id);
261
+ else failures.push(result.failure);
262
+ }
263
+ await touchSynced(db, 'orders', unchanged, { at });
264
+ summary.unchanged += unchanged.length;
265
+ const failureDetails = judgeReads('recent order reads', detailReads, failures);
266
+ const lastDay = days.length ? days[days.length - 1] : floorUtcDay(nowMs);
267
+ return {
268
+ summary,
269
+ detailReads,
270
+ listCount: byId.size,
271
+ days: days.map(formatOrderedDay),
272
+ deletedIds: deleted,
273
+ failureDetails,
274
+ cursorTime: new Date(lastDay),
275
+ cursor: { completedThrough: new Date(lastDay).toISOString() },
276
+ };
277
+ },
278
+
279
+ async orders_active(ctx) {
280
+ const { db, sync, at } = ctx;
281
+ const { targets, candidates } = await selectActiveOrders(db, {
282
+ at,
283
+ limit: sync.activeOrdersPerRun,
284
+ finalStatusPattern: FINAL_STATUS_PATTERN,
285
+ });
286
+ const summary = { created: 0, changed: 0, unchanged: 0 };
287
+ const failures = [];
288
+ const deleted = [];
289
+ for (const target of targets) {
290
+ const result = await readOrder(ctx, target.id, { known: true });
291
+ if (result.outcome === 'read') addSummary(summary, result.summary);
292
+ else if (result.outcome === 'deleted') deleted.push(target.id);
293
+ else failures.push(result.failure);
294
+ }
295
+ const quarantined = await readQuarantinedOrders(db, { at, limit: MAX_REPORTED });
296
+ const failureDetails = judgeReads('active order reads', targets.length, failures, {
297
+ quarantinedIds: quarantined.map((order) => order.id),
298
+ });
299
+ return {
300
+ summary,
301
+ detailReads: targets.length,
302
+ openOrders: candidates,
303
+ deletedIds: deleted,
304
+ failureDetails,
305
+ cursorTime: at,
306
+ };
307
+ },
308
+
309
+ async clients(ctx) {
310
+ const { client, sync, at } = ctx;
311
+ // The first run reads the whole list; later runs read changes only.
312
+ const since = ctx.state?.cursorTime ? cursorSince(ctx, 0) : '';
313
+ const items = listOrFail(await client.get('clients/', since ? { lastModified: since } : {}), 'client list', sync);
314
+ const result = await syncListWithDetail(ctx, {
315
+ mirror: 'clients',
316
+ what: 'client',
317
+ items,
318
+ keyOf: (item) => text(item?.id),
319
+ idOf: (item) => text(item?.id),
320
+ signatureOf: clientListSignature,
321
+ detailPath: (id) => `clients/${encodeURIComponent(id)}`,
322
+ map: (item, detail) => mapClient(item, detail),
323
+ });
324
+ return { ...result, cursorTime: result.anyFailed ? ctx.state?.cursorTime ?? null : at, since: since || null };
325
+ },
326
+
327
+ products: (ctx) => syncReference(ctx, 'products'),
328
+ users: (ctx) => syncReference(ctx, 'users'),
329
+ };
330
+
331
+ async function syncReference(ctx, resource) {
332
+ const { db, client, sync, at } = ctx;
333
+ const list = listOrFail(await client.get(`${resource}/`), `${resource} list`, sync);
334
+ const records = list.map((item) => mapReference(resource, item)).filter((record) => record.id);
335
+ const { stored } = await upsertReference(db, resource, records, { at });
336
+ return { listCount: list.length, stored, cursorTime: at };
337
+ }
338
+
339
+ function failureDelayMs(error, state, sync) {
340
+ if (Number.isFinite(error?.retryAfterMs) && error.retryAfterMs > 0) {
341
+ return Math.min(sync.retryMaxMs, Math.max(sync.retryBaseMs, error.retryAfterMs));
342
+ }
343
+ if (error?.held) return sync.heldRetryMs;
344
+ const attempt = (state?.failureCount ?? 0) + 1;
345
+ return Math.min(sync.retryMaxMs, sync.retryBaseMs * (2 ** Math.min(attempt - 1, 10)));
346
+ }
347
+
348
+ // Runs every lane that is due (or the ones in `only`, or all with `force`).
349
+ // Returns { ok, results } with one result per lane that ran.
350
+ export async function runSeazonaSync(db, client, { settings, now = Date.now, only = [], force = false } = {}) {
351
+ const sync = settings.sync;
352
+ const states = await readLaneStates(db);
353
+ const wanted = only.length ? LANES.filter((lane) => only.includes(lane)) : LANES;
354
+ const results = {};
355
+ for (const lane of wanted) {
356
+ const state = states.get(lane) ?? null;
357
+ const startMs = Number(now());
358
+ if (!force && !only.length && state?.nextRunAt && state.nextRunAt.getTime() > startMs) continue;
359
+ const at = new Date(startMs);
360
+ if (!(await claimLane(db, lane, { at, stuckMs: sync.stuckLaneMs }))) {
361
+ results[lane] = { ok: true, skipped: 'running_elsewhere' };
362
+ continue;
363
+ }
364
+ const cadenceMs = sync.lanes[lane].cadenceMs;
365
+ try {
366
+ const result = await laneRuns[lane]({ db, client, sync, at, state });
367
+ await finishLane(db, lane, {
368
+ status: 'ok',
369
+ cursorTime: result.cursorTime ?? state?.cursorTime ?? null,
370
+ cursor: result.cursor ?? state?.cursor ?? {},
371
+ lastSuccessAt: at,
372
+ lastAttemptAt: at,
373
+ nextRunAt: new Date(Number(now()) + cadenceMs),
374
+ failureCount: 0,
375
+ lastError: null,
376
+ failureDetails: result.failureDetails ?? null,
377
+ heldReason: null,
378
+ });
379
+ const { cursor, cursorTime, anyFailed, ...reported } = result;
380
+ results[lane] = { ok: true, ...reported };
381
+ } catch (error) {
382
+ const delayMs = failureDelayMs(error, state, sync);
383
+ const message = text(error?.message) || 'Seazona lane failed';
384
+ await finishLane(db, lane, {
385
+ status: error?.held ? 'held' : 'failed',
386
+ lastAttemptAt: at,
387
+ nextRunAt: new Date(Number(now()) + delayMs),
388
+ failureCount: (state?.failureCount ?? 0) + 1,
389
+ lastError: message,
390
+ failureDetails: error?.details ?? null,
391
+ heldReason: error?.held ? error.code : null,
392
+ });
393
+ results[lane] = { ok: false, error: message, code: error?.code ?? 'seazona_lane_failed', details: error?.details ?? null, retryAfterMs: delayMs };
394
+ }
395
+ }
396
+ return { ok: Object.values(results).every((result) => result.ok), results };
397
+ }
398
+
399
+ // What /health shows for the sync: each lane's state, the failing and
400
+ // quarantined orders by id and status, and how many orders are marked
401
+ // deleted in Seazona. No patient data.
402
+ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {}) {
403
+ const nowMs = Number(now());
404
+ const states = await readLaneStates(db);
405
+ const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
406
+ const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
407
+ const lanes = LANES.map((lane) => {
408
+ const state = states.get(lane);
409
+ const { freshnessMs } = settings.sync.lanes[lane];
410
+ const ageMs = state?.lastSuccessAt ? Math.max(0, nowMs - state.lastSuccessAt.getTime()) : null;
411
+ return {
412
+ lane,
413
+ status: state?.status ?? 'never_run',
414
+ fresh: ageMs !== null && ageMs <= freshnessMs,
415
+ ageMs,
416
+ lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
417
+ lastAttemptAt: state?.lastAttemptAt?.toISOString() ?? null,
418
+ nextRunAt: state?.nextRunAt?.toISOString() ?? null,
419
+ failureCount: state?.failureCount ?? 0,
420
+ lastError: state?.lastError ?? null,
421
+ heldReason: state?.heldReason ?? null,
422
+ failureDetails: state?.failureDetails ?? null,
423
+ ...(lane === 'orders_active' ? { quarantinedOrders } : {}),
424
+ };
425
+ });
426
+ return {
427
+ ok: lanes.every((lane) => lane.status !== 'failed' && lane.status !== 'held'),
428
+ lanes,
429
+ deletedOrders: Number(deleted),
430
+ };
431
+ }