@arcforgelabs/flow 2026.10.32 → 2026.10.34

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 (57) hide show
  1. package/README.md +9 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +58 -25
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +16 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/invoicing-rules.js +50 -4
  10. package/server/core/job-board.js +17 -4
  11. package/server/core/ledger.js +242 -6
  12. package/server/core/pipeline.js +29 -21
  13. package/server/core/read-before-act.js +96 -0
  14. package/server/core/reconcile.js +350 -0
  15. package/server/core/sending.js +44 -6
  16. package/server/core/tracing.js +64 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/0018_custom_fields.sql +78 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +62 -0
  23. package/server/index.js +11 -1
  24. package/server/integrations/port.js +10 -1
  25. package/server/integrations/seazona/act.js +48 -0
  26. package/server/integrations/seazona/feed.js +70 -50
  27. package/server/integrations/seazona/governor.js +28 -2
  28. package/server/integrations/seazona/index.js +107 -4
  29. package/server/integrations/seazona/lanes.js +81 -8
  30. package/server/integrations/seazona/ledger.js +14 -9
  31. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  32. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  33. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  34. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  35. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  36. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  37. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  38. package/server/integrations/seazona/reconcile.js +40 -0
  39. package/server/integrations/seazona/scheduler.js +32 -4
  40. package/server/integrations/seazona/schema.js +47 -0
  41. package/server/integrations/seazona/settings.js +26 -0
  42. package/server/integrations/seazona/stage-moves.js +135 -0
  43. package/server/integrations/seazona/store.js +24 -2
  44. package/server/integrations/seazona/sweep.js +574 -0
  45. package/server/integrations/seazona/tables.js +3 -0
  46. package/server/integrations/sentry/index.js +61 -10
  47. package/server/integrations/xero/index.js +4 -0
  48. package/server/integrations/xero/webhooks.js +35 -0
  49. package/server/ui/app-page.js +1 -1
  50. package/server/ui/app.js +40 -2
  51. package/server/ui/custom-fields.js +190 -0
  52. package/server/ui/flow.css +57 -9
  53. package/server/ui/invoicing.js +184 -22
  54. package/server/ui/job-board.js +2 -1
  55. package/server/ui/jobs.js +22 -2
  56. package/server/ui/monitoring.js +44 -2
  57. package/server/ui/routes.js +1 -1
@@ -0,0 +1,574 @@
1
+ import { and, asc, eq, gt, gte, inArray, isNull, like, lt, or, sql } from 'drizzle-orm';
2
+ import {
3
+ clientListSignature,
4
+ invoiceListSignature,
5
+ isFinalStatus,
6
+ mapClient,
7
+ mapInvoice,
8
+ orderListSignature,
9
+ parseSeazonaDate,
10
+ text,
11
+ } from './map.js';
12
+ import { seazonaClients, seazonaInvoices, seazonaOrders, seazonaSweepFindings } from './schema.js';
13
+ import {
14
+ clearRecordFailures,
15
+ readKnown,
16
+ readLaneStates,
17
+ readOrderFailures,
18
+ readQuarantinedRecords,
19
+ recordRecordFailure,
20
+ touchSynced,
21
+ upsertRecords,
22
+ } from './store.js';
23
+
24
+ // Issue #126: the slow re-check sweep (anti-entropy). The cursor lanes see
25
+ // only what their queries return, so three kinds of change can pass them by:
26
+ // an edit to an order older than the recent-order window that has left the
27
+ // active rotation, a record deleted in Seazona (a changed list never names
28
+ // it), and a change behind a cursor that moved too far. The sweep reads
29
+ // whole lists again on a slow cycle and compares them with Flow's copy:
30
+ //
31
+ // orders each day older than the recent-order window, newest day first:
32
+ // GET orders/?ordered=<day> and its pages. A listed order whose
33
+ // list fields changed is read again. An order Flow holds for
34
+ // that day that the list leaves out is read again: a 404 marks
35
+ // it deleted in Seazona (issue #12), and the billing feed
36
+ // withdraws it, so its invoice goes to review.
37
+ // clients the whole list (GET clients/?lastModified=), read on from the
38
+ // last row when it is full.
39
+ // invoices every change since the oldest invoice Flow holds, read on the
40
+ // same way.
41
+ //
42
+ // A client or invoice that Flow holds and the whole list leaves out is read
43
+ // by its id. A 404 marks it `missing` in seazona_sweep_findings; the row is
44
+ // never deleted, and the mark clears when Seazona lists it again. The orders
45
+ // of a missing invoice are read again at once.
46
+ //
47
+ // Each kind starts again at most once a cycle (SEAZONA_SWEEP_CYCLE_DAYS). The
48
+ // sweep makes at most SEAZONA_SWEEP_CALLS_PER_RUN calls a run and
49
+ // SEAZONA_SWEEP_CALLS_PER_DAY a UTC day, through the same rate governor and
50
+ // call guard as every lane, and keeps its place in the lane's cursor. A
51
+ // change the lane that owns it will still read (after the lane's cursor) is
52
+ // left to that lane and costs no call.
53
+ //
54
+ // What the sweep finds that the lanes missed is counted in
55
+ // seazona_sweep_findings and reported once a run. A count above zero is a
56
+ // signal to fix a lane.
57
+
58
+ export const SWEEP_LANE = 'sweep';
59
+ export const SWEEP_KINDS = Object.freeze(['orders', 'clients', 'invoices']);
60
+
61
+ const DAY_MS = 24 * 60 * 60_000;
62
+ const REPORT_WINDOW_MS = 7 * DAY_MS;
63
+ const MAX_REPORTED = 25;
64
+ const ORDER_PAGE_MAX = 500;
65
+ const CONFIRM_BATCH = 25;
66
+
67
+ class SweepBudgetSpent extends Error {}
68
+
69
+ // readOrder (lanes.js) records any error it does not know as a failed read,
70
+ // so the share is checked before each order read, not inside it.
71
+ function readOrder(ctx, helpers, id, options) {
72
+ if (!ctx.client.hasCall()) throw new SweepBudgetSpent();
73
+ return helpers.readOrder(ctx, id, options);
74
+ }
75
+
76
+ const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
77
+ const floorUtcDay = (ms) => Date.parse(dayOf(ms));
78
+ const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null);
79
+
80
+ // Seazona writes lastModified in UTC with no zone suffix.
81
+ function parseUtc(value) {
82
+ const stamp = text(value);
83
+ if (!stamp) return NaN;
84
+ return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(stamp) ? stamp : `${stamp}Z`);
85
+ }
86
+
87
+ // A client that refuses a call once the run's share is spent. A refused call
88
+ // is never sent, so it costs nothing.
89
+ function countedClient(client, budget) {
90
+ return {
91
+ governor: client.governor,
92
+ hasCall: () => budget.left > 0,
93
+ async get(path, query) {
94
+ if (budget.left <= 0) throw new SweepBudgetSpent();
95
+ budget.left -= 1;
96
+ budget.spent += 1;
97
+ return client.get(path, query);
98
+ },
99
+ };
100
+ }
101
+
102
+ async function recordFinding(db, resource, key, kind, { at, detail = null }) {
103
+ await db.insert(seazonaSweepFindings)
104
+ .values({ resource, recordKey: key, kind, firstFoundAt: at, lastFoundAt: at, detail })
105
+ .onConflictDoUpdate({
106
+ target: [seazonaSweepFindings.resource, seazonaSweepFindings.recordKey, seazonaSweepFindings.kind],
107
+ set: { lastFoundAt: at, detail },
108
+ });
109
+ }
110
+
111
+ async function clearMissing(db, resource, keys) {
112
+ if (!keys.length) return;
113
+ await db.delete(seazonaSweepFindings).where(and(
114
+ eq(seazonaSweepFindings.resource, resource),
115
+ eq(seazonaSweepFindings.kind, 'missing'),
116
+ inArray(seazonaSweepFindings.recordKey, keys),
117
+ ));
118
+ }
119
+
120
+ // The first time the owning lane's next read starts: a change at or after it
121
+ // is still that lane's to read. Null while the lane has never finished.
122
+ function laneReadsFrom(state, sync) {
123
+ if (!state?.lastSuccessAt) return null;
124
+ if (typeof state.cursor?.since === 'string') {
125
+ const since = parseUtc(state.cursor.since);
126
+ if (Number.isFinite(since)) return since;
127
+ }
128
+ const cursorMs = state.cursorTime?.getTime();
129
+ return Number.isFinite(cursorMs) ? cursorMs - sync.cursorOverlapMs : null;
130
+ }
131
+
132
+ function isDue(progress, at, sync) {
133
+ if (progress?.cycleStartedAt) return true;
134
+ const finished = Date.parse(progress?.lastFinishedAt ?? '');
135
+ return !Number.isFinite(finished) || at.getTime() - finished >= sync.sweepCycleMs;
136
+ }
137
+
138
+ // Orders: one ordered day at a time.
139
+
140
+ // The orders Flow holds for one ordered day (yyyy-mm-dd). Seazona writes the
141
+ // day as ISO text or as day/month/year.
142
+ async function heldOrdersFor(db, day) {
143
+ const [year, month, date] = day.split('-');
144
+ const dmy = `^0?${Number(date)}/0?${Number(month)}/(${year}|${year.slice(2)})(\\D|$)`;
145
+ const rows = await db.select({
146
+ id: seazonaOrders.id,
147
+ orderedText: seazonaOrders.orderedText,
148
+ lastSyncedAt: seazonaOrders.lastSyncedAt,
149
+ })
150
+ .from(seazonaOrders)
151
+ .where(and(
152
+ isNull(seazonaOrders.deletedInSourceAt),
153
+ or(like(seazonaOrders.orderedText, `${day}%`), sql`${seazonaOrders.orderedText} ~ ${dmy}`),
154
+ ));
155
+ return rows.filter((row) => parseSeazonaDate(row.orderedText) === day);
156
+ }
157
+
158
+ // The days, before the recent-order window, that Flow holds orders for,
159
+ // newest first: billing still cares most about those.
160
+ async function orderDays(db, { at, sync }) {
161
+ const windowStart = floorUtcDay(at.getTime() - sync.orderLookbackDays * DAY_MS);
162
+ const rows = await db.selectDistinct({ orderedText: seazonaOrders.orderedText }).from(seazonaOrders);
163
+ const days = new Set();
164
+ for (const { orderedText } of rows) {
165
+ const day = parseSeazonaDate(orderedText);
166
+ if (day && Date.parse(day) < windowStart) days.add(day);
167
+ }
168
+ return [...days].sort().reverse();
169
+ }
170
+
171
+ async function sweepOrderDay(ctx, day, progress, helpers, found) {
172
+ const { db, client, sync, at } = ctx;
173
+ const cycleStart = Date.parse(progress.cycleStartedAt);
174
+ const pageSize = Math.min(ORDER_PAGE_MAX, sync.listCap);
175
+ const listed = new Map();
176
+ let previous = null;
177
+ for (let page = 1; ; page += 1) {
178
+ const list = await client.get('orders/', { ordered: `${day}T00:00:00`, page, pageSize });
179
+ if (!Array.isArray(list)) throw new helpers.LaneError(`Seazona order list for ${day} was not a list`, { code: 'seazona_list_invalid' });
180
+ const ids = list.map((item) => text(item?.id)).filter(Boolean);
181
+ // A Seazona that does not page answers the same rows again: what the
182
+ // list left out is read one by one below.
183
+ if (previous && ids.length && ids.every((id) => previous.has(id))) break;
184
+ for (const item of list) {
185
+ if (text(item?.id) && parseSeazonaDate(item?.ordered) === day) listed.set(text(item.id), item);
186
+ }
187
+ const lastDay = parseSeazonaDate(list.at(-1)?.ordered);
188
+ if (list.length < pageSize || (lastDay && lastDay > day)) break;
189
+ previous = new Set(ids);
190
+ }
191
+
192
+ const held = await heldOrdersFor(db, day);
193
+ const known = await readKnownOrders(db, [...listed.keys()]);
194
+ const failures = await readOrderFailures(db, [...new Set([...listed.keys(), ...held.map((row) => row.id)])]);
195
+ // An order whose read failed this cycle waits for the next cycle.
196
+ const failedThisCycle = (id) => (failures.get(id)?.lastAttemptAt?.getTime() ?? -Infinity) >= cycleStart;
197
+
198
+ const unchanged = [];
199
+ for (const [id, item] of listed) {
200
+ const stored = known.get(id);
201
+ if (stored && !stored.deletedInSourceAt && stored.listSignature === orderListSignature(item)) {
202
+ unchanged.push(id);
203
+ continue;
204
+ }
205
+ if (failedThisCycle(id)) continue;
206
+ const result = await readOrder(ctx, helpers, id, { listItem: item, known: stored ?? null });
207
+ // A change to an open order is the active-order lane's; it would find it.
208
+ const missed = !stored || stored.deletedInSourceAt || isFinalStatus(stored.status);
209
+ if (result.outcome === 'read' && missed && (result.summary.created || result.summary.changed)) {
210
+ const kind = result.summary.created ? 'created' : 'changed';
211
+ found[kind] += 1;
212
+ await recordFinding(db, 'orders', id, kind, { at, detail: { status: text(item.status) || null } });
213
+ } else if (result.outcome === 'deleted') {
214
+ found.deleted += 1;
215
+ await recordFinding(db, 'orders', id, 'deleted', { at, detail: { httpStatus: 404 } });
216
+ }
217
+ }
218
+ await touchSynced(db, 'orders', unchanged, { at });
219
+
220
+ // Held for this day, left out of the list, and not seen this cycle.
221
+ for (const row of held) {
222
+ if (listed.has(row.id) || row.lastSyncedAt.getTime() >= cycleStart || failedThisCycle(row.id)) continue;
223
+ const result = await readOrder(ctx, helpers, row.id, { known: true });
224
+ if (result.outcome === 'deleted') {
225
+ found.deleted += 1;
226
+ await recordFinding(db, 'orders', row.id, 'deleted', { at, detail: { httpStatus: 404 } });
227
+ }
228
+ }
229
+ }
230
+
231
+ async function readKnownOrders(db, ids) {
232
+ if (!ids.length) return new Map();
233
+ const rows = await db.select({
234
+ id: seazonaOrders.id,
235
+ status: seazonaOrders.status,
236
+ listSignature: seazonaOrders.listSignature,
237
+ deletedInSourceAt: seazonaOrders.deletedInSourceAt,
238
+ }).from(seazonaOrders).where(inArray(seazonaOrders.id, ids));
239
+ return new Map(rows.map((row) => [row.id, row]));
240
+ }
241
+
242
+ async function sweepOrders(ctx, cursor, helpers, found) {
243
+ const { db, at } = ctx;
244
+ if (!cursor.orders?.cycleStartedAt) {
245
+ cursor.orders = { cycleStartedAt: at.toISOString(), days: await orderDays(db, ctx), checkedDays: 0 };
246
+ }
247
+ const progress = cursor.orders;
248
+ while (progress.days.length) {
249
+ await sweepOrderDay(ctx, progress.days[0], progress, helpers, found);
250
+ progress.days.shift();
251
+ progress.checkedDays += 1;
252
+ }
253
+ cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays };
254
+ }
255
+
256
+ // Clients and invoices: the whole list, then the held records it left out.
257
+
258
+ const WHOLE_LISTS = {
259
+ clients: {
260
+ table: seazonaClients,
261
+ key: 'id',
262
+ keyOf: (item) => text(item?.id),
263
+ idOf: (item) => text(item?.id),
264
+ signatureOf: clientListSignature,
265
+ detailPath: (id) => `clients/${encodeURIComponent(id)}`,
266
+ map: (item, detail) => mapClient(item, detail),
267
+ // Seazona answers the whole list only to an empty `?lastModified=`.
268
+ list: (client, since) => (since ? client.get('clients/', { lastModified: since }) : client.get('clients/?lastModified=')),
269
+ // "Modified after": step back a second so a row in the last row's second
270
+ // is not lost.
271
+ stepBackMs: 1000,
272
+ firstSince: async () => '',
273
+ },
274
+ invoices: {
275
+ table: seazonaInvoices,
276
+ key: 'invoiceNumber',
277
+ keyOf: (item) => text(item?.invoiceNumber),
278
+ idOf: (item) => text(item?.id),
279
+ signatureOf: invoiceListSignature,
280
+ detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
281
+ map: (item, detail, sync) => mapInvoice(item, detail, { keepPatientReference: sync.keepPatientReference }),
282
+ list: (client, since) => client.get('invoices/', { lastModified: since }),
283
+ // The guide: on or after lastModified (inclusive).
284
+ stepBackMs: 0,
285
+ // A day before the oldest change Flow holds: an invoice older than that
286
+ // was never in Flow's copy, so it cannot be missing from it.
287
+ async firstSince(db) {
288
+ const [{ oldest }] = await db.select({ oldest: sql`min(${seazonaInvoices.lastModifiedText})` }).from(seazonaInvoices);
289
+ const oldestMs = parseUtc(oldest);
290
+ return Number.isFinite(oldestMs) ? new Date(floorUtcDay(oldestMs) - DAY_MS).toISOString() : null;
291
+ },
292
+ },
293
+ };
294
+
295
+ // Compares one list answer with Flow's copy. A changed or new row that the
296
+ // owning lane has already passed is read and stored; a row the lane will
297
+ // still read is left to it.
298
+ async function compareListed(ctx, resource, items, { readsFrom, helpers, found }) {
299
+ const { db, client, sync, at } = ctx;
300
+ const spec = WHOLE_LISTS[resource];
301
+ const keyed = items.map((item) => ({ item, key: spec.keyOf(item) })).filter((entry) => entry.key);
302
+ const keys = keyed.map((entry) => entry.key);
303
+ const known = await readKnown(db, resource, keys);
304
+ const quarantined = await readQuarantinedRecords(db, resource, keys, { at });
305
+ const seen = [];
306
+ const read = [];
307
+ for (const { item, key } of keyed) {
308
+ const stored = known.get(key);
309
+ const signature = spec.signatureOf(item);
310
+ if (stored) seen.push(key);
311
+ if (stored?.listSignature === signature || quarantined.has(key)) continue;
312
+ const changedAt = parseUtc(item?.lastModified);
313
+ if (Number.isFinite(changedAt) && changedAt >= readsFrom) continue;
314
+ const id = spec.idOf(item);
315
+ let detail = null;
316
+ if (id) {
317
+ try {
318
+ detail = await client.get(spec.detailPath(id));
319
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw helpers.emptyResponse(`${resource} ${key}`);
320
+ } catch (error) {
321
+ if (error instanceof SweepBudgetSpent || helpers.isLaneLevel(error)) throw error;
322
+ await recordRecordFailure(db, resource, key, helpers.describeReadFailure(error), {
323
+ at,
324
+ quarantineFailures: sync.orderQuarantineFailures,
325
+ quarantineMs: sync.orderQuarantineMs,
326
+ });
327
+ continue;
328
+ }
329
+ }
330
+ const summary = await upsertRecords(db, resource, [spec.map(item, detail, sync)], { at, listSignatures: new Map([[key, signature]]) });
331
+ read.push(key);
332
+ if (summary.created || summary.changed) {
333
+ const kind = summary.created ? 'created' : 'changed';
334
+ found[kind] += 1;
335
+ await recordFinding(db, resource, key, kind, { at, detail: { lastModified: text(item?.lastModified) || null } });
336
+ }
337
+ }
338
+ await touchSynced(db, resource, seen, { at });
339
+ await clearRecordFailures(db, resource, read);
340
+ await clearMissing(db, resource, keys);
341
+ }
342
+
343
+ // Reads by id each held record the whole list left out (not seen since the
344
+ // cycle started), in key order.
345
+ async function confirmLeftOut(ctx, resource, progress, { helpers, found }) {
346
+ const { db, client, sync, at } = ctx;
347
+ const spec = WHOLE_LISTS[resource];
348
+ const { table } = spec;
349
+ const keyColumn = table[spec.key];
350
+ for (;;) {
351
+ const rows = await db.select({ key: keyColumn, ...(resource === 'invoices' ? { seazonaId: table.seazonaId } : {}) })
352
+ .from(table)
353
+ .where(and(
354
+ lt(table.lastSyncedAt, new Date(progress.cycleStartedAt)),
355
+ progress.afterKey ? gt(keyColumn, progress.afterKey) : undefined,
356
+ ))
357
+ .orderBy(asc(keyColumn))
358
+ .limit(CONFIRM_BATCH);
359
+ if (!rows.length) return;
360
+ const quarantined = await readQuarantinedRecords(db, resource, rows.map((row) => row.key), { at });
361
+ for (const row of rows) {
362
+ if (!quarantined.has(row.key)) await confirmOne(row);
363
+ progress.afterKey = row.key;
364
+ }
365
+ }
366
+
367
+ async function confirmOne(row) {
368
+ const id = resource === 'invoices' ? row.seazonaId : row.key;
369
+ if (!id) {
370
+ await markMissing(row.key, { confirmed: false });
371
+ return;
372
+ }
373
+ try {
374
+ const detail = await client.get(spec.detailPath(id));
375
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw helpers.emptyResponse(`${resource} ${row.key}`);
376
+ const listItem = resource === 'invoices' ? { invoiceNumber: row.key, id } : { id };
377
+ await upsertRecords(db, resource, [spec.map(listItem, detail, sync)], { at });
378
+ await clearRecordFailures(db, resource, [row.key]);
379
+ await clearMissing(db, resource, [row.key]);
380
+ } catch (error) {
381
+ if (error instanceof SweepBudgetSpent || helpers.isLaneLevel(error)) throw error;
382
+ if (error?.statusCode === 404) {
383
+ await markMissing(row.key, { confirmed: true, httpStatus: 404 });
384
+ return;
385
+ }
386
+ await recordRecordFailure(db, resource, row.key, helpers.describeReadFailure(error), {
387
+ at,
388
+ quarantineFailures: sync.orderQuarantineFailures,
389
+ quarantineMs: sync.orderQuarantineMs,
390
+ });
391
+ }
392
+ }
393
+
394
+ async function markMissing(key, detail) {
395
+ found.missing += 1;
396
+ await recordFinding(db, resource, key, 'missing', { at, detail });
397
+ if (resource !== 'invoices') return;
398
+ // The invoice is gone: read its orders again, so a deleted order leaves
399
+ // billing and its draft goes to review.
400
+ const orders = await db.select({ id: seazonaOrders.id }).from(seazonaOrders)
401
+ .where(and(eq(seazonaOrders.invoiceNumber, key), isNull(seazonaOrders.deletedInSourceAt)));
402
+ for (const { id } of orders) {
403
+ const result = await readOrder(ctx, helpers, id, { known: true });
404
+ if (result.outcome === 'deleted') {
405
+ found.deleted += 1;
406
+ await recordFinding(db, 'orders', id, 'deleted', { at, detail: { httpStatus: 404 } });
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ async function sweepWholeList(ctx, resource, cursor, laneStates, helpers, found) {
413
+ const { db, client, sync, at } = ctx;
414
+ const spec = WHOLE_LISTS[resource];
415
+ // Nothing to compare with until the owning lane has read once.
416
+ const readsFrom = laneReadsFrom(laneStates.get(resource), sync);
417
+ if (readsFrom === null) return;
418
+ if (!cursor[resource]?.cycleStartedAt) {
419
+ const since = await spec.firstSince(db);
420
+ if (since === null) {
421
+ cursor[resource] = { lastFinishedAt: at.toISOString(), listed: 0 };
422
+ return;
423
+ }
424
+ cursor[resource] = { cycleStartedAt: at.toISOString(), step: 'walk', since, listed: 0 };
425
+ }
426
+ const progress = cursor[resource];
427
+ while (progress.step === 'walk') {
428
+ const items = await spec.list(client, progress.since);
429
+ if (!Array.isArray(items)) throw new helpers.LaneError(`Seazona ${resource} list was not a list`, { code: 'seazona_list_invalid' });
430
+ await compareListed(ctx, resource, items, { readsFrom, helpers, found });
431
+ progress.listed += items.length;
432
+ if (items.length < sync.listCap) {
433
+ progress.step = 'confirm';
434
+ break;
435
+ }
436
+ // A full list goes on from its last row. Rows out of order, or a full
437
+ // list inside one second, cannot go on: the cycle ends with no
438
+ // left-out check, as the list was not read whole.
439
+ let lastMs = -Infinity;
440
+ const ordered = items.every((item) => {
441
+ const ms = parseUtc(item?.lastModified);
442
+ const ok = Number.isFinite(ms) && ms >= lastMs;
443
+ lastMs = ok ? ms : lastMs;
444
+ return ok;
445
+ });
446
+ const next = lastMs - spec.stepBackMs;
447
+ const sinceMs = progress.since ? parseUtc(progress.since) : -Infinity;
448
+ if (!ordered || !(next > sinceMs)) {
449
+ cursor[resource] = { lastFinishedAt: at.toISOString(), listed: progress.listed, incomplete: ordered ? 'one_second_full' : 'not_oldest_first' };
450
+ return;
451
+ }
452
+ progress.since = new Date(next).toISOString();
453
+ }
454
+ await confirmLeftOut(ctx, resource, progress, { helpers, found });
455
+ cursor[resource] = { lastFinishedAt: at.toISOString(), listed: progress.listed };
456
+ }
457
+
458
+ // The source high-water mark: the latest lastModified Flow holds from
459
+ // Seazona, against where the owning lane reads next. Seazona's time ahead of
460
+ // Flow's by more than the overlap can make a lane skip changes.
461
+ async function readHighWater(db, laneStates, sync, nowMs) {
462
+ const out = {};
463
+ for (const resource of ['invoices', 'clients']) {
464
+ const { table } = WHOLE_LISTS[resource];
465
+ const [{ latest }] = await db.select({ latest: sql`max(${table.lastModifiedText})` }).from(table);
466
+ const sourceMs = parseUtc(latest);
467
+ const readsFrom = laneReadsFrom(laneStates.get(resource), sync);
468
+ const aheadMs = Number.isFinite(sourceMs) ? sourceMs - nowMs : null;
469
+ out[resource] = {
470
+ sourceLastModified: Number.isFinite(sourceMs) ? iso(sourceMs) : null,
471
+ laneReadsFrom: iso(readsFrom),
472
+ gapMs: Number.isFinite(sourceMs) && readsFrom !== null ? sourceMs - readsFrom : null,
473
+ seazonaAheadMs: aheadMs !== null && aheadMs > 0 ? aheadMs : 0,
474
+ seazonaAhead: aheadMs !== null && aheadMs > sync.cursorOverlapMs,
475
+ };
476
+ }
477
+ return out;
478
+ }
479
+
480
+ // The sweep lane's run. `helpers` come from lanes.js: readOrder (issue #12
481
+ // for each order read), isLaneLevel, describeReadFailure, emptyResponse and
482
+ // LaneError.
483
+ export async function runSweep(ctx, helpers) {
484
+ const { db, sync, at, state } = ctx;
485
+ const previous = state?.cursor ?? {};
486
+ const today = dayOf(at.getTime());
487
+ const spentToday = previous.budget?.day === today ? previous.budget.calls : 0;
488
+ const budget = { left: Math.max(0, Math.min(sync.sweepCallsPerRun, sync.sweepCallsPerDay - spentToday)), spent: 0 };
489
+ const cursor = structuredClone(previous);
490
+ const found = { changed: 0, created: 0, deleted: 0, missing: 0 };
491
+ const sweepCtx = { ...ctx, client: countedClient(ctx.client, budget) };
492
+ const laneStates = await readLaneStates(db);
493
+ const save = () => ({ ...cursor, budget: { day: today, calls: spentToday + budget.spent } });
494
+
495
+ try {
496
+ for (const kind of SWEEP_KINDS) {
497
+ if (budget.left <= 0) break;
498
+ if (!isDue(cursor[kind], at, sync)) continue;
499
+ if (kind === 'orders') await sweepOrders(sweepCtx, cursor, helpers, found);
500
+ else await sweepWholeList(sweepCtx, kind, cursor, laneStates, helpers, found);
501
+ }
502
+ } catch (error) {
503
+ if (!(error instanceof SweepBudgetSpent)) {
504
+ // A lane-level failure keeps what this run checked.
505
+ if (error && typeof error === 'object') error.progress = { cursor: save() };
506
+ throw error;
507
+ }
508
+ }
509
+
510
+ const reports = [];
511
+ const total = found.changed + found.created + found.deleted + found.missing;
512
+ if (total) reports.push('the sweep found records the sync lanes missed');
513
+ const highWater = await readHighWater(db, laneStates, sync, at.getTime());
514
+ const warned = cursor.seazonaAheadWarned ?? {};
515
+ for (const [resource, mark] of Object.entries(highWater)) {
516
+ if (mark.seazonaAhead && !warned[resource]) reports.push(`Seazona's ${resource} times are ahead of Flow's clock by more than the cursor overlap`);
517
+ warned[resource] = mark.seazonaAhead;
518
+ }
519
+ cursor.seazonaAheadWarned = warned;
520
+
521
+ return {
522
+ calls: budget.spent,
523
+ callsToday: spentToday + budget.spent,
524
+ found,
525
+ reports,
526
+ cursorTime: at,
527
+ cursor: save(),
528
+ };
529
+ }
530
+
531
+ // What /health shows for the sweep. Reads Flow's database only.
532
+ export async function readSweepHealth(db, settings, { now = Date.now } = {}) {
533
+ const nowMs = Number(now());
534
+ const sync = settings.sync;
535
+ const laneStates = await readLaneStates(db);
536
+ const cursor = laneStates.get(SWEEP_LANE)?.cursor ?? {};
537
+ const counts = await db.select({
538
+ resource: seazonaSweepFindings.resource,
539
+ kind: seazonaSweepFindings.kind,
540
+ records: sql`count(*)::int`,
541
+ })
542
+ .from(seazonaSweepFindings)
543
+ .where(gte(seazonaSweepFindings.lastFoundAt, new Date(nowMs - REPORT_WINDOW_MS)))
544
+ .groupBy(seazonaSweepFindings.resource, seazonaSweepFindings.kind);
545
+ const foundLastWeek = {};
546
+ for (const row of counts) {
547
+ if (row.kind === 'missing') continue;
548
+ foundLastWeek[row.resource] = { ...foundLastWeek[row.resource], [row.kind]: row.records };
549
+ }
550
+ const missingRows = await db.select({ resource: seazonaSweepFindings.resource, key: seazonaSweepFindings.recordKey })
551
+ .from(seazonaSweepFindings)
552
+ .where(eq(seazonaSweepFindings.kind, 'missing'))
553
+ .orderBy(seazonaSweepFindings.resource, seazonaSweepFindings.recordKey);
554
+ const missing = {};
555
+ for (const row of missingRows) {
556
+ const entry = missing[row.resource] ?? (missing[row.resource] = { count: 0, keys: [] });
557
+ entry.count += 1;
558
+ if (entry.keys.length < MAX_REPORTED) entry.keys.push(row.key);
559
+ }
560
+ const today = dayOf(nowMs);
561
+ const kinds = Object.fromEntries(SWEEP_KINDS.map((kind) => [kind, {
562
+ running: Boolean(cursor[kind]?.cycleStartedAt),
563
+ lastFinishedAt: cursor[kind]?.lastFinishedAt ?? null,
564
+ ...(cursor[kind]?.incomplete ? { incomplete: cursor[kind].incomplete } : {}),
565
+ }]));
566
+ return {
567
+ callsToday: cursor.budget?.day === today ? cursor.budget.calls : 0,
568
+ callsPerDay: sync.sweepCallsPerDay,
569
+ kinds,
570
+ foundLastWeek,
571
+ missing,
572
+ highWater: await readHighWater(db, laneStates, sync, nowMs),
573
+ };
574
+ }
@@ -18,5 +18,8 @@ export const seazonaSchema = Object.freeze({
18
18
  'seazona_history_rows',
19
19
  'seazona_history_batch_jobs',
20
20
  'seazona_history_jobs',
21
+ 'seazona_sweep_findings',
22
+ 'seazona_stage_moves',
23
+ 'seazona_person_settings',
21
24
  ]),
22
25
  });