@arcforgelabs/flow 2026.10.27 → 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.
Files changed (60) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -2
  4. package/server/api/invoicing.js +96 -40
  5. package/server/app.js +3 -1
  6. package/server/core/automations.js +46 -1
  7. package/server/core/invoice-rules.js +4 -2
  8. package/server/core/invoices.js +4 -2
  9. package/server/core/job-board.js +130 -0
  10. package/server/core/ledger.js +12 -5
  11. package/server/core/line-mapping.js +337 -0
  12. package/server/core/pipeline.js +18 -6
  13. package/server/core/queue.js +22 -0
  14. package/server/core/sending.js +20 -7
  15. package/server/db/migrate-all.js +26 -0
  16. package/server/db/migrations/0010_invoicing_rules.sql +34 -13
  17. package/server/db/migrations/0011_invoice_rules.sql +11 -5
  18. package/server/db/migrations/0012_invoice_sending.sql +18 -9
  19. package/server/db/migrations/0013_line_mappings.sql +35 -0
  20. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +43 -0
  23. package/server/import/at-start.js +64 -2
  24. package/server/index.js +5 -1
  25. package/server/integrations/port.js +4 -1
  26. package/server/integrations/seazona/client.js +6 -0
  27. package/server/integrations/seazona/feed.js +62 -4
  28. package/server/integrations/seazona/governor.js +138 -13
  29. package/server/integrations/seazona/index.js +21 -2
  30. package/server/integrations/seazona/lanes.js +69 -13
  31. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  32. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  33. package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
  34. package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
  35. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  36. package/server/integrations/seazona/scheduler.js +37 -1
  37. package/server/integrations/seazona/schema.js +22 -0
  38. package/server/integrations/seazona/settings.js +10 -0
  39. package/server/integrations/seazona/store.js +74 -0
  40. package/server/integrations/seazona/tables.js +1 -0
  41. package/server/integrations/xero/drafts.js +4 -1
  42. package/server/integrations/xero/import.js +1 -0
  43. package/server/integrations/xero/lookup.js +6 -2
  44. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  45. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  46. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  47. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  48. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  49. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
  50. package/server/ui/app-page.js +6 -4
  51. package/server/ui/app.js +111 -8
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +155 -30
  54. package/server/ui/invoicing-settings.js +2 -0
  55. package/server/ui/job-board.js +155 -0
  56. package/server/ui/jobs.js +220 -91
  57. package/server/ui/line-mappings.js +60 -0
  58. package/server/ui/monitoring.js +29 -7
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/search.js +145 -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?.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) {
@@ -309,8 +358,11 @@ const laneRuns = {
309
358
  async clients(ctx) {
310
359
  const { client, sync, at } = ctx;
311
360
  // The first run reads the whole list; later runs read changes only.
361
+ // Seazona answers the whole list only to an empty `?lastModified=`; a
362
+ // bare path is a 404.
312
363
  const since = ctx.state?.cursorTime ? cursorSince(ctx, 0) : '';
313
- const items = listOrFail(await client.get('clients/', since ? { lastModified: since } : {}), 'client list', sync);
364
+ const reply = since ? await client.get('clients/', { lastModified: since }) : await client.get('clients/?lastModified=');
365
+ const items = listOrFail(reply, 'client list', sync);
314
366
  const result = await syncListWithDetail(ctx, {
315
367
  mirror: 'clients',
316
368
  what: 'client',
@@ -355,6 +407,7 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
355
407
  for (const lane of wanted) {
356
408
  const state = states.get(lane) ?? null;
357
409
  const startMs = Number(now());
410
+ if (!force && !only.length && state?.status === 'stopped') continue;
358
411
  if (!force && !only.length && state?.nextRunAt && state.nextRunAt.getTime() > startMs) continue;
359
412
  const at = new Date(startMs);
360
413
  if (!(await claimLane(db, lane, { at, stuckMs: sync.stuckLaneMs }))) {
@@ -379,18 +432,19 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
379
432
  const { cursor, cursorTime, anyFailed, ...reported } = result;
380
433
  results[lane] = { ok: true, ...reported };
381
434
  } catch (error) {
382
- const delayMs = failureDelayMs(error, state, sync);
435
+ const { status, heldReason, delayMs } = laneOutcome(error, state, sync);
383
436
  const message = text(error?.message) || 'Seazona lane failed';
437
+ const code = heldReason ?? error?.code ?? 'seazona_lane_failed';
384
438
  await finishLane(db, lane, {
385
- status: error?.held ? 'held' : 'failed',
439
+ status,
386
440
  lastAttemptAt: at,
387
- nextRunAt: new Date(Number(now()) + delayMs),
441
+ nextRunAt: delayMs === null ? null : new Date(Number(now()) + delayMs),
388
442
  failureCount: (state?.failureCount ?? 0) + 1,
389
443
  lastError: message,
390
- failureDetails: error?.details ?? null,
391
- heldReason: error?.held ? error.code : null,
444
+ failureDetails: error?.details ?? (Number.isInteger(error?.statusCode) ? { httpStatus: error.statusCode, path: error.path ?? null } : null),
445
+ heldReason,
392
446
  });
393
- 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 };
394
448
  }
395
449
  }
396
450
  return { ok: Object.values(results).every((result) => result.ok), results };
@@ -404,6 +458,7 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
404
458
  const states = await readLaneStates(db);
405
459
  const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
406
460
  const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
461
+ const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
407
462
  const lanes = LANES.map((lane) => {
408
463
  const state = states.get(lane);
409
464
  const { freshnessMs } = settings.sync.lanes[lane];
@@ -421,10 +476,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
421
476
  heldReason: state?.heldReason ?? null,
422
477
  failureDetails: state?.failureDetails ?? null,
423
478
  ...(lane === 'orders_active' ? { quarantinedOrders } : {}),
479
+ ...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
424
480
  };
425
481
  });
426
482
  return {
427
- ok: lanes.every((lane) => lane.status !== 'failed' && lane.status !== 'held'),
483
+ ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
428
484
  lanes,
429
485
  deletedOrders: Number(deleted),
430
486
  };
@@ -1,11 +1,11 @@
1
- CREATE TABLE "seazona_api_governor" (
1
+ CREATE TABLE IF NOT EXISTS "seazona_api_governor" (
2
2
  "client_key" text PRIMARY KEY NOT NULL,
3
3
  "next_eligible_at" timestamp with time zone DEFAULT 'epoch' NOT NULL,
4
4
  "cooldown_until" timestamp with time zone DEFAULT 'epoch' NOT NULL,
5
5
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
6
6
  );
7
7
  --> statement-breakpoint
8
- CREATE TABLE "seazona_api_requests" (
8
+ CREATE TABLE IF NOT EXISTS "seazona_api_requests" (
9
9
  "id" bigserial PRIMARY KEY NOT NULL,
10
10
  "client_key" text NOT NULL,
11
11
  "workflow" text NOT NULL,
@@ -17,4 +17,4 @@ CREATE TABLE "seazona_api_requests" (
17
17
  "is_retry" boolean DEFAULT false NOT NULL
18
18
  );
19
19
  --> statement-breakpoint
20
- CREATE INDEX "seazona_api_requests_window" ON "seazona_api_requests" USING btree ("client_key","admitted_at");
20
+ CREATE INDEX IF NOT EXISTS "seazona_api_requests_window" ON "seazona_api_requests" USING btree ("client_key","admitted_at");
@@ -1,4 +1,4 @@
1
- CREATE TABLE "seazona_clients" (
1
+ CREATE TABLE IF NOT EXISTS "seazona_clients" (
2
2
  "id" text PRIMARY KEY NOT NULL,
3
3
  "account_number" text,
4
4
  "company" text,
@@ -17,7 +17,7 @@ CREATE TABLE "seazona_clients" (
17
17
  "last_changed_at" timestamp with time zone NOT NULL
18
18
  );
19
19
  --> statement-breakpoint
20
- CREATE TABLE "seazona_invoices" (
20
+ CREATE TABLE IF NOT EXISTS "seazona_invoices" (
21
21
  "invoice_number" text PRIMARY KEY NOT NULL,
22
22
  "seazona_id" text,
23
23
  "client_id" text,
@@ -41,7 +41,7 @@ CREATE TABLE "seazona_invoices" (
41
41
  "last_changed_at" timestamp with time zone NOT NULL
42
42
  );
43
43
  --> statement-breakpoint
44
- CREATE TABLE "seazona_lane_state" (
44
+ CREATE TABLE IF NOT EXISTS "seazona_lane_state" (
45
45
  "lane" text PRIMARY KEY NOT NULL,
46
46
  "status" text DEFAULT 'idle' NOT NULL,
47
47
  "cursor_time" timestamp with time zone,
@@ -56,7 +56,7 @@ CREATE TABLE "seazona_lane_state" (
56
56
  "updated_at" timestamp with time zone DEFAULT now() NOT NULL
57
57
  );
58
58
  --> statement-breakpoint
59
- CREATE TABLE "seazona_order_read_failures" (
59
+ CREATE TABLE IF NOT EXISTS "seazona_order_read_failures" (
60
60
  "order_id" text PRIMARY KEY NOT NULL,
61
61
  "failure_count" integer NOT NULL,
62
62
  "last_attempt_at" timestamp with time zone NOT NULL,
@@ -66,7 +66,7 @@ CREATE TABLE "seazona_order_read_failures" (
66
66
  "quarantined_until" timestamp with time zone
67
67
  );
68
68
  --> statement-breakpoint
69
- CREATE TABLE "seazona_orders" (
69
+ CREATE TABLE IF NOT EXISTS "seazona_orders" (
70
70
  "id" text PRIMARY KEY NOT NULL,
71
71
  "invoice_number" text,
72
72
  "client_id" text,
@@ -88,7 +88,7 @@ CREATE TABLE "seazona_orders" (
88
88
  "last_changed_at" timestamp with time zone NOT NULL
89
89
  );
90
90
  --> statement-breakpoint
91
- CREATE TABLE "seazona_reference" (
91
+ CREATE TABLE IF NOT EXISTS "seazona_reference" (
92
92
  "resource" text NOT NULL,
93
93
  "id" text NOT NULL,
94
94
  "name" text,
@@ -101,6 +101,6 @@ CREATE TABLE "seazona_reference" (
101
101
  CONSTRAINT "seazona_reference_resource_id_pk" PRIMARY KEY("resource","id")
102
102
  );
103
103
  --> statement-breakpoint
104
- CREATE INDEX "seazona_invoices_changed" ON "seazona_invoices" USING btree ("last_changed_at");--> statement-breakpoint
105
- CREATE INDEX "seazona_orders_invoice_number" ON "seazona_orders" USING btree ("invoice_number");--> statement-breakpoint
106
- CREATE INDEX "seazona_orders_changed" ON "seazona_orders" USING btree ("last_changed_at");
104
+ CREATE INDEX IF NOT EXISTS "seazona_invoices_changed" ON "seazona_invoices" USING btree ("last_changed_at");--> statement-breakpoint
105
+ CREATE INDEX IF NOT EXISTS "seazona_orders_invoice_number" ON "seazona_orders" USING btree ("invoice_number");--> statement-breakpoint
106
+ CREATE INDEX IF NOT EXISTS "seazona_orders_changed" ON "seazona_orders" USING btree ("last_changed_at");
@@ -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;