@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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +46 -1
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +18 -6
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +20 -7
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +4 -1
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/governor.js +138 -13
- package/server/integrations/seazona/index.js +21 -2
- package/server/integrations/seazona/lanes.js +69 -13
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- 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/drafts.js +4 -1
- package/server/integrations/xero/import.js +1 -0
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +29 -7
- package/server/ui/routes.js +1 -1
- 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?.
|
|
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) {
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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) =>
|
|
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;
|