@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
|
@@ -36,6 +36,20 @@
|
|
|
36
36
|
"when": 1792453111948,
|
|
37
37
|
"tag": "0004_history_import",
|
|
38
38
|
"breakpoints": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"idx": 5,
|
|
42
|
+
"version": "7",
|
|
43
|
+
"when": 1792458014317,
|
|
44
|
+
"tag": "0005_record_reread",
|
|
45
|
+
"breakpoints": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"idx": 6,
|
|
49
|
+
"version": "7",
|
|
50
|
+
"when": 1792459854631,
|
|
51
|
+
"tag": "0006_sweep_findings",
|
|
52
|
+
"breakpoints": true
|
|
39
53
|
}
|
|
40
54
|
]
|
|
41
55
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readBillableWork } from './feed.js';
|
|
2
|
+
|
|
3
|
+
// The billable jobs in Flow's copy of Seazona, by day, behind the port's
|
|
4
|
+
// `billableJobs` (the daily reconcile, server/core/reconcile.js). It walks the
|
|
5
|
+
// billing feed from its start, so a job counts here exactly when the feed
|
|
6
|
+
// gives it to the ledger, on the day the ledger files it (its `jobDate`). It
|
|
7
|
+
// reads Flow's copy only; it never calls Seazona.
|
|
8
|
+
//
|
|
9
|
+
// A feed key only moves forward, so an order read again while the walk runs
|
|
10
|
+
// is counted once, and none is missed.
|
|
11
|
+
export async function readBillableJobs(db, { from, to, pageSize = 1000 } = {}) {
|
|
12
|
+
const jobs = new Map();
|
|
13
|
+
let cursor = null;
|
|
14
|
+
for (;;) {
|
|
15
|
+
const page = await readBillableWork(db, { cursor, limit: pageSize });
|
|
16
|
+
for (const { ledgerJob: job } of page.items) {
|
|
17
|
+
// A job with no lines never reaches the ledger (pipeline.js, ingestWork).
|
|
18
|
+
if (!job.lines.length) {
|
|
19
|
+
jobs.delete(job.sourceJobRef);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!job.jobDate || (from && job.jobDate < from) || (to && job.jobDate > to)) {
|
|
23
|
+
jobs.delete(job.sourceJobRef);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Unknown when a line has no amount: its price item fills it.
|
|
27
|
+
const priced = job.lines.every((l) => Number.isInteger(l.unitAmountCents));
|
|
28
|
+
jobs.set(job.sourceJobRef, {
|
|
29
|
+
ref: job.sourceJobRef,
|
|
30
|
+
day: job.jobDate,
|
|
31
|
+
subtotalCents: priced ? job.lines.reduce((sum, l) => sum + l.unitAmountCents * l.quantity, 0) : null,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// A job that stopped being billable, or was deleted, is not counted.
|
|
35
|
+
for (const { sourceId } of page.withdrawn) jobs.delete(sourceId);
|
|
36
|
+
if (!page.more) break;
|
|
37
|
+
cursor = page.cursor;
|
|
38
|
+
}
|
|
39
|
+
return { jobs: [...jobs.values()] };
|
|
40
|
+
}
|
|
@@ -6,6 +6,11 @@ import { readLaneStates } from './store.js';
|
|
|
6
6
|
// the next lane is due, between 15 seconds and 30 minutes. One run at a time.
|
|
7
7
|
// A stopped lane (issue #101) is not due until a person or a release resets it.
|
|
8
8
|
//
|
|
9
|
+
// Issue #143: a run that has not ended after `stuckMs` is stuck (for example a
|
|
10
|
+
// database call that never answers). It is reported once, /health says so,
|
|
11
|
+
// and the scheduler goes on with the next run without it. Lane claims
|
|
12
|
+
// (store.js, claimLane) keep two runs off one lane.
|
|
13
|
+
//
|
|
9
14
|
// Issue #101: each held failure is reported once to the error services
|
|
10
15
|
// (log.error, server/core/monitoring.js): when a lane starts failing or its
|
|
11
16
|
// failure code changes, and when records are quarantined. A repeat of the
|
|
@@ -23,12 +28,15 @@ export function createSeazonaScheduler({
|
|
|
23
28
|
log = console,
|
|
24
29
|
timers = { setTimeout, clearTimeout },
|
|
25
30
|
firstDelayMs = 5_000,
|
|
31
|
+
stuckMs = settings?.sync?.stuckLaneMs ?? 20 * 60_000,
|
|
26
32
|
} = {}) {
|
|
27
33
|
let timer = null;
|
|
28
34
|
let running = null;
|
|
29
35
|
let stopped = false;
|
|
30
36
|
let nextRunAt = null;
|
|
31
37
|
let last = null;
|
|
38
|
+
// When the run that is stuck now started, or null.
|
|
39
|
+
let stuckSince = null;
|
|
32
40
|
// lane -> the failure code last reported, while that lane keeps failing.
|
|
33
41
|
const reported = new Map();
|
|
34
42
|
const quarantineAt = settings?.sync?.orderQuarantineFailures ?? null;
|
|
@@ -45,6 +53,8 @@ export function createSeazonaScheduler({
|
|
|
45
53
|
reported.delete(lane);
|
|
46
54
|
const count = newlyQuarantined(outcome);
|
|
47
55
|
if (count) log.error?.(`[seazona] ${lane}: records quarantined after repeated failed reads`);
|
|
56
|
+
// Issue #126: what the sweep found, once per run that found it.
|
|
57
|
+
for (const line of outcome.reports ?? []) log.error?.(`[seazona] ${lane}: ${line}`);
|
|
48
58
|
continue;
|
|
49
59
|
}
|
|
50
60
|
if (reported.get(lane) === outcome.code) {
|
|
@@ -78,8 +88,9 @@ export function createSeazonaScheduler({
|
|
|
78
88
|
async function tick() {
|
|
79
89
|
timer = null;
|
|
80
90
|
if (stopped) return;
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
const startedAt = new Date(Number(now())).toISOString();
|
|
92
|
+
const turn = { abandoned: false };
|
|
93
|
+
const thisRun = (async () => {
|
|
83
94
|
try {
|
|
84
95
|
const result = await run();
|
|
85
96
|
const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
|
|
@@ -90,7 +101,24 @@ export function createSeazonaScheduler({
|
|
|
90
101
|
log.error?.(`[seazona] sync run failed: ${last.error}`);
|
|
91
102
|
}
|
|
92
103
|
})();
|
|
93
|
-
|
|
104
|
+
running = thisRun;
|
|
105
|
+
const watchdog = timers.setTimeout(() => {
|
|
106
|
+
if (stopped || turn.abandoned) return;
|
|
107
|
+
turn.abandoned = true;
|
|
108
|
+
stuckSince = startedAt;
|
|
109
|
+
running = null;
|
|
110
|
+
log.error?.(`[seazona] a sync run has not ended after ${Math.round(stuckMs / 60_000)} minutes; the next run starts without it`);
|
|
111
|
+
schedule(MIN_DELAY_MS);
|
|
112
|
+
}, stuckMs);
|
|
113
|
+
watchdog?.unref?.();
|
|
114
|
+
await thisRun;
|
|
115
|
+
timers.clearTimeout(watchdog);
|
|
116
|
+
// A stuck run that ends late leaves the schedule to the run after it.
|
|
117
|
+
if (turn.abandoned) {
|
|
118
|
+
if (stuckSince === startedAt) stuckSince = null;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
stuckSince = null;
|
|
94
122
|
running = null;
|
|
95
123
|
schedule(await nextDelayMs().catch(() => MIN_DELAY_MS));
|
|
96
124
|
}
|
|
@@ -108,7 +136,7 @@ export function createSeazonaScheduler({
|
|
|
108
136
|
await running;
|
|
109
137
|
},
|
|
110
138
|
status() {
|
|
111
|
-
return { running: Boolean(running), nextRunAt, lastRun: last };
|
|
139
|
+
return { running: Boolean(running), nextRunAt, lastRun: last, stuckSince };
|
|
112
140
|
},
|
|
113
141
|
};
|
|
114
142
|
}
|
|
@@ -170,9 +170,12 @@ export const seazonaOrderReadFailures = pgTable('seazona_order_read_failures', {
|
|
|
170
170
|
// Issue #101: each failed detail read of an invoice or a client, per record.
|
|
171
171
|
// Orders keep their own table (issue #12). After a few failures in a row the
|
|
172
172
|
// record is quarantined: the lane skips it and its cursor moves on.
|
|
173
|
+
// Issue #142: `detail_id` is the id its detail path needs (an invoice's key
|
|
174
|
+
// is its number), so the lane can read it again when its wait ends.
|
|
173
175
|
export const seazonaRecordReadFailures = pgTable('seazona_record_read_failures', {
|
|
174
176
|
resource: text('resource').notNull(),
|
|
175
177
|
recordKey: text('record_key').notNull(),
|
|
178
|
+
detailId: text('detail_id'),
|
|
176
179
|
failureCount: integer('failure_count').notNull(),
|
|
177
180
|
lastAttemptAt: at('last_attempt_at').notNull(),
|
|
178
181
|
lastHttpStatus: integer('last_http_status'),
|
|
@@ -271,3 +274,21 @@ export const seazonaHistoryJobs = pgTable('seazona_history_jobs', {
|
|
|
271
274
|
index('seazona_history_jobs_invoice').on(t.invoiceNumber),
|
|
272
275
|
index('seazona_history_jobs_changed').on(t.lastChangedAt),
|
|
273
276
|
]);
|
|
277
|
+
|
|
278
|
+
// Issue #126: what the sweep found that the cursor lanes missed, one row per
|
|
279
|
+
// record and kind. `changed` and `created`: an edit or a new record the lanes
|
|
280
|
+
// passed by. `deleted`: an order Seazona answered 404 for. `missing`: an
|
|
281
|
+
// invoice or client that Seazona no longer lists; the row stays, and the
|
|
282
|
+
// mark clears when Seazona lists the record again.
|
|
283
|
+
export const seazonaSweepFindings = pgTable('seazona_sweep_findings', {
|
|
284
|
+
resource: text('resource').notNull(),
|
|
285
|
+
recordKey: text('record_key').notNull(),
|
|
286
|
+
kind: text('kind').notNull(),
|
|
287
|
+
firstFoundAt: at('first_found_at').notNull(),
|
|
288
|
+
lastFoundAt: at('last_found_at').notNull(),
|
|
289
|
+
// Status codes and statuses only. Never patient data.
|
|
290
|
+
detail: jsonb('detail'),
|
|
291
|
+
}, (t) => [
|
|
292
|
+
primaryKey({ columns: [t.resource, t.recordKey, t.kind] }),
|
|
293
|
+
index('seazona_sweep_findings_found').on(t.lastFoundAt),
|
|
294
|
+
]);
|
|
@@ -102,7 +102,16 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
102
102
|
// The history backfill (docs/SEAZONA.md, "History import"). It calls
|
|
103
103
|
// Seazona only while a person has started one.
|
|
104
104
|
history: { cadenceMs: ms('SEAZONA_HISTORY_CADENCE_MS', 2 * MINUTE, MINUTE, DAY_MS), freshnessMs: 30 * DAY_MS },
|
|
105
|
+
// Issue #126: the slow re-check sweep (sweep.js). A few calls a run,
|
|
106
|
+
// inside a small daily share.
|
|
107
|
+
sweep: { cadenceMs: ms('SEAZONA_SWEEP_CADENCE_MS', 15 * MINUTE, MINUTE, DAY_MS), freshnessMs: 2 * DAY_MS },
|
|
105
108
|
},
|
|
109
|
+
// Issue #126: calls the sweep makes per run and per UTC day, at most. 0 a
|
|
110
|
+
// day turns the sweep off. Each kind of record (old orders, clients,
|
|
111
|
+
// invoices) is checked again at most once a cycle.
|
|
112
|
+
sweepCallsPerRun: clampInt(env.SEAZONA_SWEEP_CALLS_PER_RUN, 10, 1, 25),
|
|
113
|
+
sweepCallsPerDay: clampInt(env.SEAZONA_SWEEP_CALLS_PER_DAY, 100, 0, 1000),
|
|
114
|
+
sweepCycleMs: clampInt(env.SEAZONA_SWEEP_CYCLE_DAYS, 7, 1, 90) * DAY_MS,
|
|
106
115
|
// Calls the history backfill makes per run, at most: a small share of
|
|
107
116
|
// the rate limit, so the live lanes always have room.
|
|
108
117
|
historyCallsPerRun: clampInt(env.SEAZONA_HISTORY_CALLS_PER_RUN, 10, 1, 25),
|
|
@@ -128,6 +137,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
128
137
|
// Issue #12: after this many failed reads in a row an order waits this long.
|
|
129
138
|
orderQuarantineFailures: clampInt(env.SEAZONA_ORDER_QUARANTINE_FAILURES, 3, 1, 20),
|
|
130
139
|
orderQuarantineMs: ms('SEAZONA_ORDER_QUARANTINE_MS', DAY_MS, 5 * MINUTE, 30 * DAY_MS),
|
|
140
|
+
// Issue #142: the invoice and client lanes read this many quarantined
|
|
141
|
+
// records again per run, once their wait has ended.
|
|
142
|
+
recordRereadsPerRun: clampInt(env.SEAZONA_RECORD_REREADS_PER_RUN, 5, 0, 50),
|
|
131
143
|
// A failed lane waits 1 minute, doubling to at most 6 hours.
|
|
132
144
|
retryBaseMs: MINUTE,
|
|
133
145
|
retryMaxMs: 6 * 60 * MINUTE,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from 'drizzle-orm';
|
|
2
2
|
import { hashOf } from './map.js';
|
|
3
3
|
import {
|
|
4
4
|
seazonaClients,
|
|
@@ -215,11 +215,12 @@ export async function clearRecordFailures(db, resource, keys) {
|
|
|
215
215
|
|
|
216
216
|
// Records one failed read, as recordOrderFailure does for orders. Answers
|
|
217
217
|
// { failureCount, quarantinedUntil }.
|
|
218
|
-
export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs }) {
|
|
218
|
+
export async function recordRecordFailure(db, resource, key, failure, { at, quarantineFailures, quarantineMs, detailId = null }) {
|
|
219
219
|
const until = new Date(at.getTime() + quarantineMs);
|
|
220
220
|
const [row] = await db.insert(seazonaRecordReadFailures).values({
|
|
221
221
|
resource,
|
|
222
222
|
recordKey: key,
|
|
223
|
+
detailId,
|
|
223
224
|
failureCount: 1,
|
|
224
225
|
lastAttemptAt: at,
|
|
225
226
|
lastHttpStatus: failure.httpStatus,
|
|
@@ -229,6 +230,7 @@ export async function recordRecordFailure(db, resource, key, failure, { at, quar
|
|
|
229
230
|
target: [seazonaRecordReadFailures.resource, seazonaRecordReadFailures.recordKey],
|
|
230
231
|
set: {
|
|
231
232
|
failureCount: sql`${seazonaRecordReadFailures.failureCount} + 1`,
|
|
233
|
+
detailId: sql`coalesce(${detailId}::text, ${seazonaRecordReadFailures.detailId})`,
|
|
232
234
|
lastAttemptAt: at,
|
|
233
235
|
lastHttpStatus: failure.httpStatus,
|
|
234
236
|
lastFailureKind: failure.kind,
|
|
@@ -239,6 +241,26 @@ export async function recordRecordFailure(db, resource, key, failure, { at, quar
|
|
|
239
241
|
return { failureCount: row.failureCount, quarantinedUntil: row.quarantinedUntil?.toISOString() ?? null };
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
// Issue #142: quarantined records whose wait has ended, oldest wait first,
|
|
245
|
+
// that the lane can read again by id.
|
|
246
|
+
export async function readRecordsDueForReread(db, resource, { at, limit }) {
|
|
247
|
+
return db.select().from(seazonaRecordReadFailures).where(and(
|
|
248
|
+
eq(seazonaRecordReadFailures.resource, resource),
|
|
249
|
+
isNotNull(seazonaRecordReadFailures.detailId),
|
|
250
|
+
lte(seazonaRecordReadFailures.quarantinedUntil, at),
|
|
251
|
+
)).orderBy(asc(seazonaRecordReadFailures.quarantinedUntil)).limit(limit);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Per resource: quarantined records whose wait has ended, so they wait for a
|
|
255
|
+
// re-read.
|
|
256
|
+
export async function readRecordRereadCounts(db, { at }) {
|
|
257
|
+
const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
|
|
258
|
+
.from(seazonaRecordReadFailures)
|
|
259
|
+
.where(lte(seazonaRecordReadFailures.quarantinedUntil, at))
|
|
260
|
+
.groupBy(seazonaRecordReadFailures.resource);
|
|
261
|
+
return Object.fromEntries(rows.map((row) => [row.resource, row.records]));
|
|
262
|
+
}
|
|
263
|
+
|
|
242
264
|
export async function readQuarantinedRecordCounts(db, { at }) {
|
|
243
265
|
const rows = await db.select({ resource: seazonaRecordReadFailures.resource, records: sql`count(*)::int` })
|
|
244
266
|
.from(seazonaRecordReadFailures)
|