@arcforgelabs/flow 2026.10.29 → 2026.10.30
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/core/automations.js +32 -1
- package/server/core/pipeline.js +9 -3
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +16 -4
- package/server/integrations/port.js +3 -1
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/governor.js +138 -13
- package/server/integrations/seazona/index.js +14 -1
- package/server/integrations/seazona/lanes.js +65 -12
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +22 -0
- package/server/integrations/seazona/settings.js +10 -0
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/tables.js +1 -0
- package/server/integrations/xero/import.js +1 -0
- package/server/ui/monitoring.js +14 -0
|
@@ -13,12 +13,16 @@ import { seazonaOrders } from './schema.js';
|
|
|
13
13
|
import {
|
|
14
14
|
claimLane,
|
|
15
15
|
clearOrderFailure,
|
|
16
|
+
clearRecordFailures,
|
|
16
17
|
finishLane,
|
|
17
18
|
markOrderDeleted,
|
|
18
19
|
readKnown,
|
|
19
20
|
readLaneStates,
|
|
20
21
|
readQuarantinedOrders,
|
|
22
|
+
readQuarantinedRecordCounts,
|
|
23
|
+
readQuarantinedRecords,
|
|
21
24
|
recordOrderFailure,
|
|
25
|
+
recordRecordFailure,
|
|
22
26
|
selectActiveOrders,
|
|
23
27
|
touchSynced,
|
|
24
28
|
upsertRecords,
|
|
@@ -39,6 +43,13 @@ import {
|
|
|
39
43
|
// Seazona, an empty answer is a failure, each failure is recorded per order
|
|
40
44
|
// and quarantines it after a few in a row, and a lane fails only when most of
|
|
41
45
|
// its reads fail. Errors and /health name each failing order and its status.
|
|
46
|
+
//
|
|
47
|
+
// Issue #101 holds for every lane: invoices and clients quarantine a record
|
|
48
|
+
// after a few failed reads too, so one bad record never holds a cursor. A
|
|
49
|
+
// list call that Seazona refuses (400, 401, 403, 404) means the path or the
|
|
50
|
+
// API client is wrong, and no wait fixes it: the lane stops and runs again
|
|
51
|
+
// only after a person saves the settings or Flow restarts on a new release.
|
|
52
|
+
// Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
|
|
42
53
|
|
|
43
54
|
export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users']);
|
|
44
55
|
|
|
@@ -73,7 +84,23 @@ function listOrFail(list, what, sync) {
|
|
|
73
84
|
// A failure that is about Seazona or the rate limit, not one record: the
|
|
74
85
|
// whole lane stops and waits.
|
|
75
86
|
function isLaneLevel(error) {
|
|
76
|
-
return error?.statusCode === 429 || error?.
|
|
87
|
+
return error?.statusCode === 429 || error?.statusCode === 401 || error?.statusCode === 403
|
|
88
|
+
|| error?.kind === 'governor_timeout' || error?.kind === 'calls_stopped';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Answers how a lane that threw should wait: { status, heldReason, delayMs }.
|
|
92
|
+
// A null delay means the lane is stopped until a person or a release.
|
|
93
|
+
const REJECTED = new Set([400, 401, 403, 404]);
|
|
94
|
+
function laneOutcome(error, state, sync) {
|
|
95
|
+
if (error?.kind === 'calls_stopped') {
|
|
96
|
+
if (error.reason === 'halted') return { status: 'stopped', heldReason: error.code, delayMs: null };
|
|
97
|
+
return { status: 'failed', heldReason: null, delayMs: Math.max(sync.retryBaseMs, error.retryAfterMs ?? 0) };
|
|
98
|
+
}
|
|
99
|
+
if (error?.name === 'SeazonaApiError' && REJECTED.has(error.statusCode)) {
|
|
100
|
+
const auth = error.statusCode === 401 || error.statusCode === 403;
|
|
101
|
+
return { status: 'stopped', heldReason: auth ? 'seazona_api_halted' : 'seazona_request_rejected', delayMs: null };
|
|
102
|
+
}
|
|
103
|
+
return { status: error?.held ? 'held' : 'failed', heldReason: error?.held ? error.code : null, delayMs: failureDelayMs(error, state, sync) };
|
|
77
104
|
}
|
|
78
105
|
|
|
79
106
|
function describeReadFailure(error) {
|
|
@@ -156,12 +183,16 @@ function addSummary(total, summary) {
|
|
|
156
183
|
// Invoices and clients: a list since a cursor, then a detail read for each
|
|
157
184
|
// row whose list fields changed.
|
|
158
185
|
async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signatureOf, detailPath, map }) {
|
|
159
|
-
const { db, client, at } = ctx;
|
|
186
|
+
const { db, client, sync, at } = ctx;
|
|
160
187
|
const keyed = items.map((item) => ({ item, key: keyOf(item) })).filter((entry) => entry.key);
|
|
161
188
|
const known = await readKnown(db, mirror, keyed.map((entry) => entry.key));
|
|
189
|
+
const quarantined = await readQuarantinedRecords(db, mirror, keyed.map((entry) => entry.key), { at });
|
|
162
190
|
const summary = { created: 0, changed: 0, unchanged: 0 };
|
|
163
191
|
const unchanged = [];
|
|
192
|
+
const read = [];
|
|
164
193
|
const failures = [];
|
|
194
|
+
const skipped = [];
|
|
195
|
+
let newlyQuarantined = 0;
|
|
165
196
|
let detailReads = 0;
|
|
166
197
|
for (const { item, key } of keyed) {
|
|
167
198
|
const signature = signatureOf(item);
|
|
@@ -169,6 +200,11 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
169
200
|
unchanged.push(key);
|
|
170
201
|
continue;
|
|
171
202
|
}
|
|
203
|
+
// Issue #101: a quarantined record costs no call until its wait ends.
|
|
204
|
+
if (quarantined.has(key)) {
|
|
205
|
+
skipped.push(key);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
172
208
|
const id = idOf(item);
|
|
173
209
|
let detail = null;
|
|
174
210
|
if (id) {
|
|
@@ -178,16 +214,29 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
|
|
|
178
214
|
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${key}`);
|
|
179
215
|
} catch (error) {
|
|
180
216
|
if (isLaneLevel(error)) throw error;
|
|
181
|
-
|
|
217
|
+
const failure = describeReadFailure(error);
|
|
218
|
+
const recorded = await recordRecordFailure(db, mirror, key, failure, {
|
|
219
|
+
at,
|
|
220
|
+
quarantineFailures: sync.orderQuarantineFailures,
|
|
221
|
+
quarantineMs: sync.orderQuarantineMs,
|
|
222
|
+
});
|
|
223
|
+
if (recorded.quarantinedUntil) newlyQuarantined += 1;
|
|
224
|
+
failures.push({ id: key, ...failure, ...recorded });
|
|
182
225
|
continue;
|
|
183
226
|
}
|
|
184
227
|
}
|
|
228
|
+
read.push(key);
|
|
185
229
|
addSummary(summary, await upsertRecords(db, mirror, [map(item, detail)], { at, listSignatures: new Map([[key, signature]]) }));
|
|
186
230
|
}
|
|
187
231
|
await touchSynced(db, mirror, unchanged, { at });
|
|
232
|
+
await clearRecordFailures(db, mirror, read);
|
|
188
233
|
summary.unchanged += unchanged.length;
|
|
189
|
-
const
|
|
190
|
-
|
|
234
|
+
const extra = skipped.length ? { quarantinedIds: skipped.slice(0, MAX_REPORTED) } : {};
|
|
235
|
+
const failureDetails = judgeReads(`${what} reads`, detailReads, failures, extra);
|
|
236
|
+
// A quarantined record no longer holds the cursor; a record that may still
|
|
237
|
+
// be read again does.
|
|
238
|
+
const anyFailed = failures.some((failure) => !failure.quarantinedUntil);
|
|
239
|
+
return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
|
|
191
240
|
}
|
|
192
241
|
|
|
193
242
|
function cursorSince(ctx, fallbackMs) {
|
|
@@ -358,6 +407,7 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
358
407
|
for (const lane of wanted) {
|
|
359
408
|
const state = states.get(lane) ?? null;
|
|
360
409
|
const startMs = Number(now());
|
|
410
|
+
if (!force && !only.length && state?.status === 'stopped') continue;
|
|
361
411
|
if (!force && !only.length && state?.nextRunAt && state.nextRunAt.getTime() > startMs) continue;
|
|
362
412
|
const at = new Date(startMs);
|
|
363
413
|
if (!(await claimLane(db, lane, { at, stuckMs: sync.stuckLaneMs }))) {
|
|
@@ -382,18 +432,19 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
|
|
|
382
432
|
const { cursor, cursorTime, anyFailed, ...reported } = result;
|
|
383
433
|
results[lane] = { ok: true, ...reported };
|
|
384
434
|
} catch (error) {
|
|
385
|
-
const delayMs =
|
|
435
|
+
const { status, heldReason, delayMs } = laneOutcome(error, state, sync);
|
|
386
436
|
const message = text(error?.message) || 'Seazona lane failed';
|
|
437
|
+
const code = heldReason ?? error?.code ?? 'seazona_lane_failed';
|
|
387
438
|
await finishLane(db, lane, {
|
|
388
|
-
status
|
|
439
|
+
status,
|
|
389
440
|
lastAttemptAt: at,
|
|
390
|
-
nextRunAt: new Date(Number(now()) + delayMs),
|
|
441
|
+
nextRunAt: delayMs === null ? null : new Date(Number(now()) + delayMs),
|
|
391
442
|
failureCount: (state?.failureCount ?? 0) + 1,
|
|
392
443
|
lastError: message,
|
|
393
|
-
failureDetails: error?.details ?? null,
|
|
394
|
-
heldReason
|
|
444
|
+
failureDetails: error?.details ?? (Number.isInteger(error?.statusCode) ? { httpStatus: error.statusCode, path: error.path ?? null } : null),
|
|
445
|
+
heldReason,
|
|
395
446
|
});
|
|
396
|
-
results[lane] = { ok: false, error: message, code
|
|
447
|
+
results[lane] = { ok: false, status, error: message, code, details: error?.details ?? null, retryAfterMs: delayMs };
|
|
397
448
|
}
|
|
398
449
|
}
|
|
399
450
|
return { ok: Object.values(results).every((result) => result.ok), results };
|
|
@@ -407,6 +458,7 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
|
|
|
407
458
|
const states = await readLaneStates(db);
|
|
408
459
|
const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
|
|
409
460
|
const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
|
|
461
|
+
const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
|
|
410
462
|
const lanes = LANES.map((lane) => {
|
|
411
463
|
const state = states.get(lane);
|
|
412
464
|
const { freshnessMs } = settings.sync.lanes[lane];
|
|
@@ -424,10 +476,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
|
|
|
424
476
|
heldReason: state?.heldReason ?? null,
|
|
425
477
|
failureDetails: state?.failureDetails ?? null,
|
|
426
478
|
...(lane === 'orders_active' ? { quarantinedOrders } : {}),
|
|
479
|
+
...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
|
|
427
480
|
};
|
|
428
481
|
});
|
|
429
482
|
return {
|
|
430
|
-
ok: lanes.every((lane) =>
|
|
483
|
+
ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
|
|
431
484
|
lanes,
|
|
432
485
|
deletedOrders: Number(deleted),
|
|
433
486
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS "seazona_record_read_failures" (
|
|
2
|
+
"resource" text NOT NULL,
|
|
3
|
+
"record_key" text NOT NULL,
|
|
4
|
+
"failure_count" integer NOT NULL,
|
|
5
|
+
"last_attempt_at" timestamp with time zone NOT NULL,
|
|
6
|
+
"last_http_status" integer,
|
|
7
|
+
"last_failure_kind" text,
|
|
8
|
+
"quarantined_until" timestamp with time zone,
|
|
9
|
+
CONSTRAINT "seazona_record_read_failures_resource_record_key_pk" PRIMARY KEY("resource","record_key")
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "circuit_open_until" timestamp with time zone DEFAULT 'epoch' NOT NULL;--> statement-breakpoint
|
|
13
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "circuit_opens" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
|
14
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "halted_reason" text;--> statement-breakpoint
|
|
15
|
+
ALTER TABLE "seazona_api_governor" ADD COLUMN IF NOT EXISTS "halted_at" timestamp with time zone;
|