@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.
@@ -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?.kind === 'governor_timeout';
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
- failures.push({ id: key, ...describeReadFailure(error) });
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 failureDetails = judgeReads(`${what} reads`, detailReads, failures);
190
- return { summary, detailReads, listCount: items.length, failureDetails, anyFailed: failures.length > 0 };
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 = failureDelayMs(error, state, sync);
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: error?.held ? 'held' : 'failed',
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: error?.held ? error.code : null,
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: error?.code ?? 'seazona_lane_failed', details: error?.details ?? null, retryAfterMs: delayMs };
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) => lane.status !== 'failed' && lane.status !== 'held'),
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;