@arcforgelabs/flow 2026.10.32 → 2026.10.33
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 +3 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +56 -24
- package/server/app.js +4 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +15 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/index.js +5 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +60 -3
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +21 -0
- package/server/integrations/seazona/settings.js +12 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/flow.css +9 -0
- package/server/ui/invoicing.js +101 -17
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +19 -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import { findInvoices, listCustomers, listPriceItems, mirrorState } from './look
|
|
|
9
9
|
import { createXeroRouter } from './routes.js';
|
|
10
10
|
import { createXeroRuntime, requireXeroSettings, XERO_CALL_GUARD } from './runtime.js';
|
|
11
11
|
import { writesAllowed } from './settings.js';
|
|
12
|
+
import { readChangesFirst } from './webhooks.js';
|
|
12
13
|
|
|
13
14
|
// The Xero adapter (docs/XERO.md). FLOW_INTEGRATIONS=xero turns it on and
|
|
14
15
|
// XERO_MODE says which Xero it acts on. Its tables are created only while it
|
|
@@ -131,6 +132,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
131
132
|
// Xero's invoice for one billable job, looked up live (ledger.js): the
|
|
132
133
|
// `liveCheck` for draftInvoiceForObligation.
|
|
133
134
|
checkInvoice: ({ organisationId, obligation }) => xeroLiveCheck(database, current(), organisationId)(obligation),
|
|
135
|
+
// Xero's latest changes into the copy just before Flow drafts by rule or
|
|
136
|
+
// sends (webhooks.js); no call when the copy is current.
|
|
137
|
+
readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
|
|
134
138
|
// Active Xero contacts, from Flow's copy (lookup.js).
|
|
135
139
|
listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
|
|
136
140
|
// Sales invoices from the mirror, with the mirror's state (lookup.js).
|
|
@@ -262,6 +262,41 @@ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
|
|
|
262
262
|
return due;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Read before act (issue #45, step 5): just before Flow drafts by rule or
|
|
266
|
+
// sends a batch, Xero's latest changes reach the copy. The copy counts as
|
|
267
|
+
// current when webhooks arrive and none waits, or when it read Xero's changes
|
|
268
|
+
// in the last READ_FIRST_MS. Otherwise this reads the waiting records and the
|
|
269
|
+
// invoice and credit note changes now: about two calls. Answers { state }:
|
|
270
|
+
// fresh no call was needed
|
|
271
|
+
// read the copy read Xero now; `changed` counts invoices that changed
|
|
272
|
+
// unavailable the read could not run (`reason`); act on nothing
|
|
273
|
+
export const READ_FIRST_MS = 10 * 60 * 1000;
|
|
274
|
+
|
|
275
|
+
export async function readChangesFirst(db, runtime, organisationId, { now = Date.now } = {}) {
|
|
276
|
+
const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
|
|
277
|
+
where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
|
|
278
|
+
if (!connection) return { state: 'unavailable', reason: 'Xero is not connected' };
|
|
279
|
+
const { rows: [{ waiting }] } = await db.execute(sql`select count(*)::int as waiting from xero_webhook_events
|
|
280
|
+
where organisation_id = ${organisationId} and pending`);
|
|
281
|
+
if (!waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
|
|
282
|
+
const { rows: [{ oldest }] } = await db.execute(sql`select min(last_read_at) as oldest from xero_mirror_cursors
|
|
283
|
+
where organisation_id = ${organisationId} and resource in ('invoices', 'credit_notes')`);
|
|
284
|
+
if (!waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
|
|
285
|
+
try {
|
|
286
|
+
const inbox = await readInbox(db, runtime, organisationId, { now });
|
|
287
|
+
if (inbox.stopped) return { state: 'unavailable', reason: inbox.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero calls are stopped' };
|
|
288
|
+
const reads = await mirrorXero(db, runtime, organisationId, { resources: ['invoices', 'credit_notes'], maxPages: 5 });
|
|
289
|
+
const short = reads.find((r) => !['done', 'refused'].includes(r.stopped));
|
|
290
|
+
if (short) return { state: 'unavailable', reason: short.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero has more changes than one read takes' };
|
|
291
|
+
const invoices = reads.find((r) => r.resource === 'invoices');
|
|
292
|
+
const changed = inbox.invoices.inserted + inbox.invoices.changed + (invoices?.inserted ?? 0) + (invoices?.changed ?? 0);
|
|
293
|
+
if (changed && !inbox.sent) await checkSentInvoices(db, runtime, organisationId);
|
|
294
|
+
return { state: 'read', changed };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return { state: 'unavailable', reason: isCallsStopped(error) ? 'Xero calls are stopped' : `reading Xero failed (${error?.code ?? 'error'})` };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
// After a failed read, an organisation waits 1 minute, then 2, 4 and up to an
|
|
266
301
|
// hour, so a failing read is never tried every tick.
|
|
267
302
|
export const RETRY_FIRST_MS = 60 * 1000;
|
package/server/ui/flow.css
CHANGED
|
@@ -105,6 +105,15 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
105
105
|
border-radius: var(--t-radius-lg); box-shadow: var(--t-shadow);
|
|
106
106
|
}
|
|
107
107
|
.fx-panel { overflow: hidden; }
|
|
108
|
+
/* A list that is loading: a thin moving line on top. A list from another
|
|
109
|
+
tab, shown until the new one comes, is dimmed and cannot be clicked. */
|
|
110
|
+
.fx-panel[aria-busy="true"] { position: relative; }
|
|
111
|
+
.fx-panel[aria-busy="true"]::before {
|
|
112
|
+
content: ""; position: absolute; inset: 0 0 auto; height: 2px; z-index: 1;
|
|
113
|
+
background: linear-gradient(90deg, transparent, var(--t-accent), transparent) 0 0 / 200% 100%;
|
|
114
|
+
animation: fx-shimmer 1s linear infinite;
|
|
115
|
+
}
|
|
116
|
+
.fx-panel[data-stale] { opacity: .5; pointer-events: none; transition: opacity .15s; }
|
|
108
117
|
|
|
109
118
|
/* The signed-in app: a standard left sidebar, then the view. */
|
|
110
119
|
.fx-shell { min-height: 100vh; }
|