@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
@@ -36,6 +36,27 @@
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
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1792472936253,
58
+ "tag": "0007_stage_moves",
59
+ "breakpoints": true
39
60
  }
40
61
  ]
41
62
  }
@@ -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
- running = (async () => {
82
- const startedAt = new Date(Number(now())).toISOString();
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
- await running;
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,47 @@ 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
+ ]);
295
+
296
+ // A person's stage move on the Jobs board that Seazona must make, because
297
+ // Seazona's API cannot change an order's status (docs/SEAZONA.md, "Stage
298
+ // moves"). One row per order: the latest move. `state` is waiting until the
299
+ // sync reads the new status (done), a day passes (expired), or the person
300
+ // takes it back (cancelled). Statuses only; never patient data.
301
+ export const seazonaStageMoves = pgTable('seazona_stage_moves', {
302
+ orderId: text('order_id').primaryKey(),
303
+ fromStatus: text('from_status'),
304
+ toStatus: text('to_status').notNull(),
305
+ requestedBy: text('requested_by'),
306
+ requestedAt: at('requested_at').notNull(),
307
+ expiresAt: at('expires_at').notNull(),
308
+ state: text('state').notNull().default('waiting'),
309
+ settledAt: at('settled_at'),
310
+ }, (t) => [
311
+ index('seazona_stage_moves_state').on(t.state, t.expiresAt),
312
+ ]);
313
+
314
+ // Each person's own Seazona choices. `ask_before_stage_moves`: show the
315
+ // "Move in Seazona" dialog before a stage move on the Jobs board.
316
+ export const seazonaPersonSettings = pgTable('seazona_person_settings', {
317
+ personId: text('person_id').primaryKey(),
318
+ askBeforeStageMoves: boolean('ask_before_stage_moves').notNull().default(true),
319
+ updatedAt: at('updated_at').notNull(),
320
+ });
@@ -55,6 +55,16 @@ export function normaliseBaseUrl(value) {
55
55
  return `${url.origin}${path}/v1`;
56
56
  }
57
57
 
58
+ function orderUrlTemplate(value) {
59
+ const raw = text(value);
60
+ if (!raw.includes('{ref}')) return null;
61
+ try {
62
+ return new URL(raw.replaceAll('{ref}', 'x')).protocol === 'https:' ? raw : null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
58
68
  // Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
59
69
  // null when SEAZONA_MODE is unset: the adapter is then not configured.
60
70
  export function resolveSeazonaSettings(env = process.env) {
@@ -102,7 +112,16 @@ export function resolveSeazonaSettings(env = process.env) {
102
112
  // The history backfill (docs/SEAZONA.md, "History import"). It calls
103
113
  // Seazona only while a person has started one.
104
114
  history: { cadenceMs: ms('SEAZONA_HISTORY_CADENCE_MS', 2 * MINUTE, MINUTE, DAY_MS), freshnessMs: 30 * DAY_MS },
115
+ // Issue #126: the slow re-check sweep (sweep.js). A few calls a run,
116
+ // inside a small daily share.
117
+ sweep: { cadenceMs: ms('SEAZONA_SWEEP_CADENCE_MS', 15 * MINUTE, MINUTE, DAY_MS), freshnessMs: 2 * DAY_MS },
105
118
  },
119
+ // Issue #126: calls the sweep makes per run and per UTC day, at most. 0 a
120
+ // day turns the sweep off. Each kind of record (old orders, clients,
121
+ // invoices) is checked again at most once a cycle.
122
+ sweepCallsPerRun: clampInt(env.SEAZONA_SWEEP_CALLS_PER_RUN, 10, 1, 25),
123
+ sweepCallsPerDay: clampInt(env.SEAZONA_SWEEP_CALLS_PER_DAY, 100, 0, 1000),
124
+ sweepCycleMs: clampInt(env.SEAZONA_SWEEP_CYCLE_DAYS, 7, 1, 90) * DAY_MS,
106
125
  // Calls the history backfill makes per run, at most: a small share of
107
126
  // the rate limit, so the live lanes always have room.
108
127
  historyCallsPerRun: clampInt(env.SEAZONA_HISTORY_CALLS_PER_RUN, 10, 1, 25),
@@ -128,6 +147,9 @@ export function resolveSeazonaSettings(env = process.env) {
128
147
  // Issue #12: after this many failed reads in a row an order waits this long.
129
148
  orderQuarantineFailures: clampInt(env.SEAZONA_ORDER_QUARANTINE_FAILURES, 3, 1, 20),
130
149
  orderQuarantineMs: ms('SEAZONA_ORDER_QUARANTINE_MS', DAY_MS, 5 * MINUTE, 30 * DAY_MS),
150
+ // Issue #142: the invoice and client lanes read this many quarantined
151
+ // records again per run, once their wait has ended.
152
+ recordRereadsPerRun: clampInt(env.SEAZONA_RECORD_REREADS_PER_RUN, 5, 0, 50),
131
153
  // A failed lane waits 1 minute, doubling to at most 6 hours.
132
154
  retryBaseMs: MINUTE,
133
155
  retryMaxMs: 6 * 60 * MINUTE,
@@ -149,6 +171,10 @@ export function resolveSeazonaSettings(env = process.env) {
149
171
  rate,
150
172
  // The scheduler starts only in live mode (issue #10).
151
173
  scheduled: mode === 'live',
174
+ // The address of one order in Seazona's own screens, with {ref} for the
175
+ // order number, for "Open in Seazona" on a stage move. Optional; one
176
+ // without {ref} is ignored.
177
+ orderUrlTemplate: orderUrlTemplate(env.SEAZONA_ORDER_URL),
152
178
  };
153
179
 
154
180
  if (mode === 'live' || mode === 'sandbox') {
@@ -0,0 +1,135 @@
1
+ import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
2
+ import { FlowError } from '../../core/errors.js';
3
+ import { orderStatusLabel } from './map.js';
4
+ import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schema.js';
5
+
6
+ // Stage moves on the Jobs board (docs/SEAZONA.md, "Stage moves"). Seazona's
7
+ // API cannot change an order's status, so Flow never tries: a move becomes a
8
+ // hand-off. Flow records the move, the person makes it in Seazona, and the
9
+ // next sync that reads the new status settles it. Until then the card shows
10
+ // "Waiting for Seazona" in the new lane. After a day with no change the move
11
+ // expires, the card goes back to Seazona's stage, and a quiet alert says so.
12
+ // Every function here reads and writes Flow's database only.
13
+
14
+ export const HAND_OFF_MS = 24 * 60 * 60_000;
15
+ // How long an expired move stays in the alert.
16
+ const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
17
+ const MAX_STATUS = 80;
18
+
19
+ // The address that opens one order in Seazona, from SEAZONA_ORDER_URL with
20
+ // its {ref} placeholder, or null when the installation has not set one.
21
+ export function orderUrl(template, ref) {
22
+ if (!template || !template.includes('{ref}')) return null;
23
+ return template.replaceAll('{ref}', encodeURIComponent(ref));
24
+ }
25
+
26
+ // Record a person's move of one order to `toStatus`, and answer the hand-off
27
+ // the board shows. A second move of the same order replaces the first.
28
+ export async function requestStageMove(db, { orderId, toStatus, personId = null, orderUrlTemplate = null, now = Date.now() }) {
29
+ const to = typeof toStatus === 'string' ? toStatus.trim() : '';
30
+ if (!to || to.length > MAX_STATUS) throw new FlowError('invalid', 'toStatus must be a Seazona status');
31
+ const [order] = await db.select().from(seazonaOrders).where(eq(seazonaOrders.id, String(orderId ?? '')));
32
+ if (!order || order.deletedInSourceAt) throw new FlowError('not_found', 'Flow has no Seazona order with that number');
33
+ const known = await db.selectDistinct({ status: seazonaOrders.status }).from(seazonaOrders)
34
+ .where(eq(sql`lower(trim(${seazonaOrders.status}))`, to.toLowerCase()));
35
+ if (!known.length) throw new FlowError('invalid', `Seazona has no status named ${to}`);
36
+ const from = orderStatusLabel(order) || null;
37
+ if (from && from.toLowerCase() === to.toLowerCase()) throw new FlowError('conflict', `The order is already ${from} in Seazona`);
38
+ const at = new Date(Number(now));
39
+ const row = {
40
+ orderId: order.id, fromStatus: from, toStatus: known[0].status.trim(), requestedBy: personId,
41
+ requestedAt: at, expiresAt: new Date(at.getTime() + HAND_OFF_MS), state: 'waiting', settledAt: null,
42
+ };
43
+ await db.insert(seazonaStageMoves).values(row).onConflictDoUpdate({ target: seazonaStageMoves.orderId, set: row });
44
+ return handOff(row, { orderUrlTemplate });
45
+ }
46
+
47
+ // Take a waiting move back: the card returns to Seazona's stage.
48
+ export async function cancelStageMove(db, { orderId, now = Date.now() }) {
49
+ const rows = await db.update(seazonaStageMoves).set({ state: 'cancelled', settledAt: new Date(Number(now)) })
50
+ .where(and(eq(seazonaStageMoves.orderId, String(orderId ?? '')), eq(seazonaStageMoves.state, 'waiting'))).returning();
51
+ return { cancelled: rows.length > 0 };
52
+ }
53
+
54
+ // Settle the waiting moves against Flow's copy of the orders: done when the
55
+ // order has the new status, expired when a day has passed. An order that
56
+ // moved to some other status settles as done too: Seazona has the last word.
57
+ export async function settleStageMoves(db, { now = Date.now() } = {}) {
58
+ const at = new Date(Number(now));
59
+ const waiting = await db.select({ move: seazonaStageMoves, order: seazonaOrders }).from(seazonaStageMoves)
60
+ .leftJoin(seazonaOrders, eq(seazonaOrders.id, seazonaStageMoves.orderId))
61
+ .where(eq(seazonaStageMoves.state, 'waiting'));
62
+ const done = [];
63
+ const expired = [];
64
+ for (const { move, order } of waiting) {
65
+ const status = orderStatusLabel(order) || null;
66
+ if (status && status !== move.fromStatus) done.push(move.orderId);
67
+ else if (move.expiresAt <= at) expired.push(move.orderId);
68
+ }
69
+ if (done.length) {
70
+ await db.update(seazonaStageMoves).set({ state: 'done', settledAt: at })
71
+ .where(and(inArray(seazonaStageMoves.orderId, done), eq(seazonaStageMoves.state, 'waiting')));
72
+ }
73
+ if (expired.length) {
74
+ await db.update(seazonaStageMoves).set({ state: 'expired', settledAt: at })
75
+ .where(and(inArray(seazonaStageMoves.orderId, expired), eq(seazonaStageMoves.state, 'waiting')));
76
+ }
77
+ return { done: done.length, expired: expired.length };
78
+ }
79
+
80
+ // The waiting moves, by order id, for the board's cards.
81
+ export async function waitingStageMoves(db) {
82
+ const rows = await db.select().from(seazonaStageMoves).where(eq(seazonaStageMoves.state, 'waiting'));
83
+ return new Map(rows.map((r) => [r.orderId, r]));
84
+ }
85
+
86
+ // The moves that expired in the last week, for the quiet alert.
87
+ export async function expiredStageMoves(db, { now = Date.now() } = {}) {
88
+ return db.select().from(seazonaStageMoves).where(and(
89
+ eq(seazonaStageMoves.state, 'expired'),
90
+ gte(seazonaStageMoves.settledAt, new Date(Number(now) - EXPIRED_ALERT_MS)),
91
+ lt(seazonaStageMoves.settledAt, new Date(Number(now) + 1)),
92
+ ));
93
+ }
94
+
95
+ // What the board shows for a waiting move.
96
+ export function handOff(row, { orderUrlTemplate = null } = {}) {
97
+ return {
98
+ kind: 'hand_off',
99
+ system: 'seazona',
100
+ sourceJobRef: row.orderId,
101
+ caseRef: row.orderId,
102
+ from: row.fromStatus,
103
+ to: row.toStatus,
104
+ state: row.state,
105
+ requestedAt: row.requestedAt.toISOString(),
106
+ expiresAt: row.expiresAt.toISOString(),
107
+ openUrl: orderUrl(orderUrlTemplate, row.orderId),
108
+ };
109
+ }
110
+
111
+ // Each person's own Seazona choices, with their defaults.
112
+ export const PERSON_SETTINGS = Object.freeze([
113
+ {
114
+ key: 'askBeforeStageMoves',
115
+ label: 'Ask before stage moves',
116
+ hint: 'On the Jobs board, show what to do in Seazona before a card moves to a Seazona stage.',
117
+ kind: 'onoff',
118
+ default: true,
119
+ },
120
+ ]);
121
+
122
+ export async function readPersonSettings(db, personId) {
123
+ const [row] = await db.select().from(seazonaPersonSettings).where(eq(seazonaPersonSettings.personId, String(personId)));
124
+ return PERSON_SETTINGS.map((s) => ({ ...s, value: row ? Boolean(row[s.key]) : s.default }));
125
+ }
126
+
127
+ export async function writePersonSetting(db, personId, { key, value }, { now = Date.now() } = {}) {
128
+ const setting = PERSON_SETTINGS.find((s) => s.key === key);
129
+ if (!setting) throw new FlowError('invalid', 'no such setting');
130
+ if (typeof value !== 'boolean') throw new FlowError('invalid', `${setting.label} must be on or off`);
131
+ const at = new Date(Number(now));
132
+ await db.insert(seazonaPersonSettings).values({ personId: String(personId), [key]: value, updatedAt: at })
133
+ .onConflictDoUpdate({ target: seazonaPersonSettings.personId, set: { [key]: value, updatedAt: at } });
134
+ return readPersonSettings(db, personId);
135
+ }
@@ -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)