@arcforgelabs/flow 2026.10.32 → 2026.10.34

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 (57) hide show
  1. package/README.md +9 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +58 -25
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +16 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/invoicing-rules.js +50 -4
  10. package/server/core/job-board.js +17 -4
  11. package/server/core/ledger.js +242 -6
  12. package/server/core/pipeline.js +29 -21
  13. package/server/core/read-before-act.js +96 -0
  14. package/server/core/reconcile.js +350 -0
  15. package/server/core/sending.js +44 -6
  16. package/server/core/tracing.js +64 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/0018_custom_fields.sql +78 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +62 -0
  23. package/server/index.js +11 -1
  24. package/server/integrations/port.js +10 -1
  25. package/server/integrations/seazona/act.js +48 -0
  26. package/server/integrations/seazona/feed.js +70 -50
  27. package/server/integrations/seazona/governor.js +28 -2
  28. package/server/integrations/seazona/index.js +107 -4
  29. package/server/integrations/seazona/lanes.js +81 -8
  30. package/server/integrations/seazona/ledger.js +14 -9
  31. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  32. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  33. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  34. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  35. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  36. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  37. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  38. package/server/integrations/seazona/reconcile.js +40 -0
  39. package/server/integrations/seazona/scheduler.js +32 -4
  40. package/server/integrations/seazona/schema.js +47 -0
  41. package/server/integrations/seazona/settings.js +26 -0
  42. package/server/integrations/seazona/stage-moves.js +135 -0
  43. package/server/integrations/seazona/store.js +24 -2
  44. package/server/integrations/seazona/sweep.js +574 -0
  45. package/server/integrations/seazona/tables.js +3 -0
  46. package/server/integrations/sentry/index.js +61 -10
  47. package/server/integrations/xero/index.js +4 -0
  48. package/server/integrations/xero/webhooks.js +35 -0
  49. package/server/ui/app-page.js +1 -1
  50. package/server/ui/app.js +40 -2
  51. package/server/ui/custom-fields.js +190 -0
  52. package/server/ui/flow.css +57 -9
  53. package/server/ui/invoicing.js +184 -22
  54. package/server/ui/job-board.js +2 -1
  55. package/server/ui/jobs.js +22 -2
  56. package/server/ui/monitoring.js +44 -2
  57. package/server/ui/routes.js +1 -1
@@ -11,6 +11,7 @@ import {
11
11
  } from './map.js';
12
12
  import { runHistoryBackfill } from './history/backfill.js';
13
13
  import { seazonaOrders } from './schema.js';
14
+ import { runSweep } from './sweep.js';
14
15
  import {
15
16
  claimLane,
16
17
  clearOrderFailure,
@@ -22,6 +23,8 @@ import {
22
23
  readQuarantinedOrders,
23
24
  readQuarantinedRecordCounts,
24
25
  readQuarantinedRecords,
26
+ readRecordRereadCounts,
27
+ readRecordsDueForReread,
25
28
  recordOrderFailure,
26
29
  recordRecordFailure,
27
30
  selectActiveOrders,
@@ -41,6 +44,8 @@ import {
41
44
  // users the lab user list, daily
42
45
  // history past orders, a few calls per run, only while a person
43
46
  // has started a backfill (history/backfill.js)
47
+ // sweep whole lists again, slowly, for what the others missed
48
+ // (issue #126, sweep.js)
44
49
  //
45
50
  // Issue #12 holds for every order read: a 404 marks the order deleted in
46
51
  // Seazona, an empty answer is a failure, each failure is recorded per order
@@ -58,7 +63,7 @@ import {
58
63
  // only after a person saves the settings or Flow restarts on a new release.
59
64
  // Calls the governor stopped (SeazonaCallsStopped) wait out its pause.
60
65
 
61
- export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history']);
66
+ export const LANES = Object.freeze(['invoices', 'orders_recent', 'orders_active', 'clients', 'products', 'users', 'history', 'sweep']);
62
67
 
63
68
  const DAY_MS = 24 * 60 * 60_000;
64
69
  const MAX_REPORTED = 25;
@@ -237,6 +242,7 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
237
242
  at,
238
243
  quarantineFailures: sync.orderQuarantineFailures,
239
244
  quarantineMs: sync.orderQuarantineMs,
245
+ detailId: id,
240
246
  });
241
247
  if (recorded.quarantinedUntil) newlyQuarantined += 1;
242
248
  failures.push({ id: key, ...failure, ...recorded });
@@ -257,6 +263,52 @@ async function syncListWithDetail(ctx, { mirror, what, items, keyOf, idOf, signa
257
263
  return { summary, detailReads, listCount: items.length, failureDetails, anyFailed, newlyQuarantined, quarantined: skipped.length };
258
264
  }
259
265
 
266
+ // Issue #142: a quarantined invoice or client is in a later list only if it
267
+ // changes again, so when its wait ends the lane reads its detail by id. A
268
+ // success stores it and clears the failure; a 404 clears the failure (the
269
+ // record is gone, issue #126); a failure quarantines it again, with no new
270
+ // report. Each read goes through the call guard like any other.
271
+ async function rereadReleased(ctx, { mirror, what, detailPath, map }) {
272
+ const { db, client, sync, at } = ctx;
273
+ const due = sync.recordRereadsPerRun ? await readRecordsDueForReread(db, mirror, { at, limit: sync.recordRereadsPerRun }) : [];
274
+ const result = { summary: { created: 0, changed: 0, unchanged: 0 }, detailReads: 0, reread: 0, gone: 0, failed: 0 };
275
+ for (const row of due) {
276
+ result.detailReads += 1;
277
+ try {
278
+ const detail = await client.get(detailPath(row.detailId));
279
+ if (!detail || typeof detail !== 'object' || Array.isArray(detail)) throw emptyResponse(`${what} ${row.recordKey}`);
280
+ addSummary(result.summary, await upsertRecords(db, mirror, [map(detail)], { at }));
281
+ await clearRecordFailures(db, mirror, [row.recordKey]);
282
+ result.reread += 1;
283
+ } catch (error) {
284
+ if (isLaneLevel(error)) throw error;
285
+ if (error?.statusCode === 404) {
286
+ await clearRecordFailures(db, mirror, [row.recordKey]);
287
+ result.gone += 1;
288
+ continue;
289
+ }
290
+ await recordRecordFailure(db, mirror, row.recordKey, describeReadFailure(error), {
291
+ at,
292
+ quarantineFailures: sync.orderQuarantineFailures,
293
+ quarantineMs: sync.orderQuarantineMs,
294
+ });
295
+ result.failed += 1;
296
+ }
297
+ }
298
+ return result;
299
+ }
300
+
301
+ // Adds the re-reads to a lane's result.
302
+ function withRereads(result, reread) {
303
+ if (!reread.detailReads) return result;
304
+ addSummary(result.summary, reread.summary);
305
+ return {
306
+ ...result,
307
+ detailReads: result.detailReads + reread.detailReads,
308
+ rereads: { attempted: reread.detailReads, read: reread.reread, gone: reread.gone, failed: reread.failed },
309
+ };
310
+ }
311
+
260
312
  // Where the next changed-list read starts. A run that stopped inside a
261
313
  // window keeps the exact start of the next part (`cursor.since`); otherwise
262
314
  // the last finished run's start, less the overlap.
@@ -351,7 +403,13 @@ const laneRuns = {
351
403
  const { client, sync, at } = ctx;
352
404
  const firstSince = cursorSince(ctx, new Date(floorUtcDay(at.getTime()) - sync.invoiceLookbackDays * DAY_MS).toISOString());
353
405
  const mapOptions = { keepPatientReference: sync.keepPatientReference };
354
- return walkChangedList(ctx, {
406
+ const reread = await rereadReleased(ctx, {
407
+ mirror: 'invoices',
408
+ what: 'invoice',
409
+ detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
410
+ map: (detail) => mapInvoice({}, detail, mapOptions),
411
+ });
412
+ return withRereads(await walkChangedList(ctx, {
355
413
  what: 'invoice',
356
414
  firstSince,
357
415
  // The guide: on or after lastModified (inclusive).
@@ -367,7 +425,7 @@ const laneRuns = {
367
425
  detailPath: (id) => `invoices/${encodeURIComponent(id)}`,
368
426
  map: (item, detail) => mapInvoice(item, detail, mapOptions),
369
427
  }),
370
- });
428
+ }), reread);
371
429
  },
372
430
 
373
431
  async orders_recent(ctx) {
@@ -485,10 +543,16 @@ const laneRuns = {
485
543
 
486
544
  async clients(ctx) {
487
545
  const { client } = ctx;
546
+ const reread = await rereadReleased(ctx, {
547
+ mirror: 'clients',
548
+ what: 'client',
549
+ detailPath: (id) => `clients/${encodeURIComponent(id)}`,
550
+ map: (detail) => mapClient({}, detail),
551
+ });
488
552
  // The first run reads the whole list; later runs read changes only.
489
553
  // Seazona answers the whole list only to an empty `?lastModified=`; a
490
554
  // bare path is a 404.
491
- return walkChangedList(ctx, {
555
+ return withRereads(await walkChangedList(ctx, {
492
556
  what: 'client',
493
557
  firstSince: cursorSince(ctx, ''),
494
558
  // The guide says "modified after": step back a second so a row in the
@@ -505,10 +569,11 @@ const laneRuns = {
505
569
  detailPath: (id) => `clients/${encodeURIComponent(id)}`,
506
570
  map: (item, detail) => mapClient(item, detail),
507
571
  }),
508
- });
572
+ }), reread);
509
573
  },
510
574
 
511
575
  history: (ctx) => runHistoryBackfill(ctx, { isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
576
+ sweep: (ctx) => runSweep(ctx, { readOrder, isLaneLevel, describeReadFailure, emptyResponse, LaneError }),
512
577
  products: (ctx) => syncReference(ctx, 'products'),
513
578
  users: (ctx) => syncReference(ctx, 'users'),
514
579
  };
@@ -589,20 +654,27 @@ export async function runSeazonaSync(db, client, { settings, now = Date.now, onl
589
654
  // What /health shows for the sync: each lane's state, the failing and
590
655
  // quarantined orders by id and status, and how many orders are marked
591
656
  // deleted in Seazona. No patient data.
657
+ //
658
+ // Issue #143: a lane that ran before and has not succeeded within its
659
+ // freshness limit (or two of its own cadences, if those are longer) is
660
+ // stale, and a stale lane makes `ok` false like a failed one.
592
661
  export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {}) {
593
662
  const nowMs = Number(now());
594
663
  const states = await readLaneStates(db);
595
664
  const quarantinedOrders = await readQuarantinedOrders(db, { at: new Date(nowMs), limit: MAX_REPORTED });
596
665
  const [{ deleted }] = await db.select({ deleted: count() }).from(seazonaOrders).where(isNotNull(seazonaOrders.deletedInSourceAt));
597
666
  const quarantinedRecords = await readQuarantinedRecordCounts(db, { at: new Date(nowMs) });
667
+ const rereadDue = await readRecordRereadCounts(db, { at: new Date(nowMs) });
598
668
  const lanes = LANES.map((lane) => {
599
669
  const state = states.get(lane);
600
- const { freshnessMs } = settings.sync.lanes[lane];
670
+ const { freshnessMs, cadenceMs } = settings.sync.lanes[lane];
671
+ const staleAfterMs = Math.max(freshnessMs, 2 * cadenceMs);
601
672
  const ageMs = state?.lastSuccessAt ? Math.max(0, nowMs - state.lastSuccessAt.getTime()) : null;
602
673
  return {
603
674
  lane,
604
675
  status: state?.status ?? 'never_run',
605
- fresh: ageMs !== null && ageMs <= freshnessMs,
676
+ fresh: ageMs !== null && ageMs <= staleAfterMs,
677
+ stale: ageMs !== null && ageMs > staleAfterMs,
606
678
  ageMs,
607
679
  lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null,
608
680
  lastAttemptAt: state?.lastAttemptAt?.toISOString() ?? null,
@@ -613,10 +685,11 @@ export async function readSeazonaSyncHealth(db, settings, { now = Date.now } = {
613
685
  failureDetails: state?.failureDetails ?? null,
614
686
  ...(lane === 'orders_active' ? { quarantinedOrders } : {}),
615
687
  ...(quarantinedRecords[lane] ? { quarantinedRecords: quarantinedRecords[lane] } : {}),
688
+ ...(rereadDue[lane] ? { rereadDue: rereadDue[lane] } : {}),
616
689
  };
617
690
  });
618
691
  return {
619
- ok: lanes.every((lane) => !['failed', 'held', 'stopped'].includes(lane.status)),
692
+ ok: lanes.every((lane) => !lane.stale && !['failed', 'held', 'stopped'].includes(lane.status)),
620
693
  lanes,
621
694
  deletedOrders: Number(deleted),
622
695
  };
@@ -2,8 +2,8 @@
2
2
  // issue #3). The adapter does not import the ledger: the core reads the
3
3
  // billing feed and passes these shapes to it.
4
4
  //
5
- // item.ledgerJob -> observeJob(db, organisationId, { ...item.ledgerJob, contactId })
6
- // withdrawn.ledgerHold -> setHold(db, organisationId, <obligation id>, { ...ledgerHold, actor })
5
+ // item.ledgerJob -> observeJob(db, organisationId, { ...item.ledgerJob, contactId })
6
+ // withdrawn.ledgerWithdrawn -> observeWithdrawn(db, organisationId, withdrawn.ledgerWithdrawn)
7
7
  //
8
8
  // One Seazona order is one billable job. The contact is Flow's, so the core
9
9
  // resolves it (from item.client) and adds contactId; the ledger keeps a job
@@ -56,15 +56,20 @@ export function toLedgerJob(item) {
56
56
  };
57
57
  }
58
58
 
59
- // An order deleted in Seazona sends its obligation, if there is one, to
60
- // review: a person decides what happens to any invoice already made. An order
61
- // that only stopped being billable needs nothing from the ledger.
62
- export function toLedgerHold(entry) {
63
- if (entry.reason !== 'deleted_in_seazona') return null;
59
+ // observeWithdrawn's input for an order that is no longer billable. An
60
+ // order deleted in Seazona sends its obligation, if there is one, to review:
61
+ // a person decides what happens to any invoice already made. An order whose
62
+ // status moved back (for example from Shipped to In Production) loses an
63
+ // unsent ready mark, and goes to review when its invoice was made.
64
+ export function toLedgerWithdrawn(entry) {
65
+ const deleted = entry.reason === 'deleted_in_seazona';
64
66
  return {
65
67
  sourceSystem: SEAZONA_SOURCE_SYSTEM,
66
68
  sourceJobRef: entry.sourceId,
67
- holdState: 'review',
68
- reason: 'the order was deleted in Seazona',
69
+ sourceStatus: entry.status ?? null,
70
+ deleted,
71
+ reason: deleted ? 'the order was deleted in Seazona' : null,
72
+ observedAt: entry.syncedAt ?? null,
73
+ actor: `system:${SEAZONA_SOURCE_SYSTEM}`,
69
74
  };
70
75
  }
@@ -0,0 +1 @@
1
+ ALTER TABLE "seazona_record_read_failures" ADD COLUMN IF NOT EXISTS "detail_id" text;
@@ -0,0 +1,11 @@
1
+ CREATE TABLE IF NOT EXISTS "seazona_sweep_findings" (
2
+ "resource" text NOT NULL,
3
+ "record_key" text NOT NULL,
4
+ "kind" text NOT NULL,
5
+ "first_found_at" timestamp with time zone NOT NULL,
6
+ "last_found_at" timestamp with time zone NOT NULL,
7
+ "detail" jsonb,
8
+ CONSTRAINT "seazona_sweep_findings_resource_record_key_kind_pk" PRIMARY KEY("resource","record_key","kind")
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE INDEX IF NOT EXISTS "seazona_sweep_findings_found" ON "seazona_sweep_findings" USING btree ("last_found_at");
@@ -0,0 +1,18 @@
1
+ CREATE TABLE IF NOT EXISTS "seazona_person_settings" (
2
+ "person_id" text PRIMARY KEY NOT NULL,
3
+ "ask_before_stage_moves" boolean DEFAULT true NOT NULL,
4
+ "updated_at" timestamp with time zone NOT NULL
5
+ );
6
+ --> statement-breakpoint
7
+ CREATE TABLE IF NOT EXISTS "seazona_stage_moves" (
8
+ "order_id" text PRIMARY KEY NOT NULL,
9
+ "from_status" text,
10
+ "to_status" text NOT NULL,
11
+ "requested_by" text,
12
+ "requested_at" timestamp with time zone NOT NULL,
13
+ "expires_at" timestamp with time zone NOT NULL,
14
+ "state" text DEFAULT 'waiting' NOT NULL,
15
+ "settled_at" timestamp with time zone
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE INDEX IF NOT EXISTS "seazona_stage_moves_state" ON "seazona_stage_moves" USING btree ("state","expires_at");