@arcforgelabs/flow 2026.10.21 → 2026.10.22
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 +2 -1
- package/VISION.md +40 -9
- package/npm-shrinkwrap.json +2 -414
- package/package.json +6 -1
- package/server/core/links.js +153 -0
- package/server/core/outbox.js +129 -0
- package/server/core/queue.js +142 -0
- package/server/core/review.js +92 -0
- package/server/db/migrations/0005_data_spine.sql +99 -0
- package/server/db/migrations/meta/0005_snapshot.json +2145 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +130 -0
- package/server/ui/routes.js +9 -2
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { externalLinks, LINK_ENTITIES, LINK_MATCHES } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
import { openReview, resolveReviewFor } from './review.js';
|
|
5
|
+
|
|
6
|
+
// The identity map (issue 57). It records that a Flow record and a record in
|
|
7
|
+
// another system are the same thing, how Flow knows, and who confirmed it.
|
|
8
|
+
// Pushes, imports and the ledger read the link first; matching by name only
|
|
9
|
+
// ever proposes one.
|
|
10
|
+
//
|
|
11
|
+
// A link is exact when Flow made the external record, or both sides carry the
|
|
12
|
+
// same id. A name match is a guess: it stays `proposed` and opens a review item
|
|
13
|
+
// until a person confirms it. A link is never deleted; a replaced one is
|
|
14
|
+
// `superseded`.
|
|
15
|
+
|
|
16
|
+
const EXACT = new Set(['created_by_flow', 'exact_id', 'imported', 'person']);
|
|
17
|
+
|
|
18
|
+
function check({ entity, flowId, system, externalId, matchedBy }) {
|
|
19
|
+
if (!LINK_ENTITIES.includes(entity)) throw new FlowError('invalid', `unknown link entity: ${entity}`);
|
|
20
|
+
if (!LINK_MATCHES.includes(matchedBy)) throw new FlowError('invalid', `unknown match: ${matchedBy}`);
|
|
21
|
+
if (!flowId || !system || !externalId) throw new FlowError('invalid', 'flowId, system and externalId are required');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Two callers that link the same external record, or the same Flow record, at
|
|
25
|
+
// the same time take turns: the decision below reads, then writes.
|
|
26
|
+
async function lockPair(tx, organisationId, { system, entity, externalId, flowId }) {
|
|
27
|
+
const keys = [
|
|
28
|
+
`link:${organisationId}:${system}:${entity}:ext:${externalId}`,
|
|
29
|
+
`link:${organisationId}:${system}:${entity}:flow:${flowId}`,
|
|
30
|
+
].sort();
|
|
31
|
+
for (const key of keys) await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function activeBy(tx, organisationId, { system, entity }, column, value) {
|
|
35
|
+
const [row] = await tx.select().from(externalLinks).where(and(
|
|
36
|
+
eq(externalLinks.organisationId, organisationId),
|
|
37
|
+
eq(externalLinks.system, system),
|
|
38
|
+
eq(externalLinks.entity, entity),
|
|
39
|
+
eq(column, value),
|
|
40
|
+
eq(externalLinks.state, 'active'),
|
|
41
|
+
));
|
|
42
|
+
return row ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Records a link. Answers `{ outcome, link }`:
|
|
46
|
+
// - `linked`: a new active link;
|
|
47
|
+
// - `existing`: the same active link was already there;
|
|
48
|
+
// - `proposed`: a guess, waiting for a person (a review item is open);
|
|
49
|
+
// - `conflict`: one side already links elsewhere. Nothing changes; a review
|
|
50
|
+
// item holds both sides.
|
|
51
|
+
export async function linkRecord(db, organisationId, link) {
|
|
52
|
+
check(link);
|
|
53
|
+
const { entity, flowId, system, externalId, matchedBy } = link;
|
|
54
|
+
return db.transaction(async (tx) => {
|
|
55
|
+
await lockPair(tx, organisationId, link);
|
|
56
|
+
const byExternal = await activeBy(tx, organisationId, link, externalLinks.externalId, externalId);
|
|
57
|
+
if (byExternal?.flowId === flowId) return { outcome: 'existing', link: byExternal };
|
|
58
|
+
const byFlow = await activeBy(tx, organisationId, link, externalLinks.flowId, flowId);
|
|
59
|
+
if (byExternal || byFlow) {
|
|
60
|
+
await openReview(tx, organisationId, {
|
|
61
|
+
kind: 'link.conflict',
|
|
62
|
+
entity,
|
|
63
|
+
flowId,
|
|
64
|
+
systems: [system],
|
|
65
|
+
evidence: {
|
|
66
|
+
proposed: { system, externalId, matchedBy },
|
|
67
|
+
linkedExternal: byExternal && { flowId: byExternal.flowId, externalId: byExternal.externalId },
|
|
68
|
+
linkedFlow: byFlow && { flowId: byFlow.flowId, externalId: byFlow.externalId },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return { outcome: 'conflict', link: byExternal ?? byFlow };
|
|
72
|
+
}
|
|
73
|
+
const state = EXACT.has(matchedBy) ? 'active' : 'proposed';
|
|
74
|
+
const [waiting] = await tx.select().from(externalLinks).where(and(
|
|
75
|
+
eq(externalLinks.organisationId, organisationId),
|
|
76
|
+
eq(externalLinks.system, system),
|
|
77
|
+
eq(externalLinks.entity, entity),
|
|
78
|
+
eq(externalLinks.externalId, externalId),
|
|
79
|
+
eq(externalLinks.flowId, flowId),
|
|
80
|
+
eq(externalLinks.state, 'proposed'),
|
|
81
|
+
));
|
|
82
|
+
if (waiting && state === 'proposed') return { outcome: 'proposed', link: waiting };
|
|
83
|
+
if (waiting) {
|
|
84
|
+
// Exact evidence for a pair a person was asked about settles the question.
|
|
85
|
+
await tx.update(externalLinks).set({ state: 'superseded', updatedAt: sql`now()` })
|
|
86
|
+
.where(eq(externalLinks.id, waiting.id));
|
|
87
|
+
await resolveReviewFor(tx, organisationId, {
|
|
88
|
+
kind: 'link.proposed', entity, flowId, resolution: `exact:${matchedBy}`, actor: 'system:links',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const [row] = await tx.insert(externalLinks)
|
|
92
|
+
.values({ organisationId, entity, flowId, system, externalId, matchedBy, state })
|
|
93
|
+
.returning();
|
|
94
|
+
if (state === 'proposed') {
|
|
95
|
+
await openReview(tx, organisationId, {
|
|
96
|
+
kind: 'link.proposed',
|
|
97
|
+
entity,
|
|
98
|
+
flowId,
|
|
99
|
+
systems: [system],
|
|
100
|
+
evidence: { linkId: row.id, system, externalId, matchedBy },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { outcome: state === 'active' ? 'linked' : 'proposed', link: row };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A person confirms a proposed link. It becomes active, unless either side
|
|
108
|
+
// gained an active link meanwhile.
|
|
109
|
+
export async function confirmLink(db, organisationId, linkId, { personId = null, actor }) {
|
|
110
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
111
|
+
return db.transaction(async (tx) => {
|
|
112
|
+
const [proposed] = await tx.select().from(externalLinks)
|
|
113
|
+
.where(and(eq(externalLinks.organisationId, organisationId), eq(externalLinks.id, linkId)));
|
|
114
|
+
if (!proposed) throw notFound('link');
|
|
115
|
+
await lockPair(tx, organisationId, proposed);
|
|
116
|
+
const [current] = await tx.select().from(externalLinks)
|
|
117
|
+
.where(eq(externalLinks.id, linkId)).for('update');
|
|
118
|
+
if (current.state !== 'proposed') throw new FlowError('conflict', 'link is not waiting for a person', { state: current.state });
|
|
119
|
+
const taken = await activeBy(tx, organisationId, current, externalLinks.externalId, current.externalId)
|
|
120
|
+
?? await activeBy(tx, organisationId, current, externalLinks.flowId, current.flowId);
|
|
121
|
+
if (taken) throw new FlowError('conflict', 'one side already has an active link', { linkId: taken.id });
|
|
122
|
+
const [row] = await tx.update(externalLinks)
|
|
123
|
+
.set({ state: 'active', confirmedBy: personId, confirmedAt: sql`now()`, updatedAt: sql`now()` })
|
|
124
|
+
.where(eq(externalLinks.id, linkId))
|
|
125
|
+
.returning();
|
|
126
|
+
await resolveReviewFor(tx, organisationId, {
|
|
127
|
+
kind: 'link.proposed', entity: row.entity, flowId: row.flowId, resolution: 'confirmed', actor,
|
|
128
|
+
});
|
|
129
|
+
return row;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Ends a link (for example when the external record was merged or deleted).
|
|
134
|
+
export async function supersedeLink(db, organisationId, linkId) {
|
|
135
|
+
const [row] = await db.update(externalLinks)
|
|
136
|
+
.set({ state: 'superseded', updatedAt: sql`now()` })
|
|
137
|
+
.where(and(eq(externalLinks.organisationId, organisationId), eq(externalLinks.id, linkId)))
|
|
138
|
+
.returning();
|
|
139
|
+
if (!row) throw notFound('link');
|
|
140
|
+
return row;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The Flow record an external record is, through an active link, or null.
|
|
144
|
+
export async function flowIdFor(db, organisationId, { system, entity, externalId }) {
|
|
145
|
+
const row = await activeBy(db, organisationId, { system, entity }, externalLinks.externalId, externalId);
|
|
146
|
+
return row?.flowId ?? null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The external id of a Flow record in one system, through an active link, or null.
|
|
150
|
+
export async function externalIdFor(db, organisationId, { system, entity, flowId }) {
|
|
151
|
+
const row = await activeBy(db, organisationId, { system, entity }, externalLinks.flowId, flowId);
|
|
152
|
+
return row?.externalId ?? null;
|
|
153
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { outbox } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
import { linkRecord } from './links.js';
|
|
5
|
+
import { openReview } from './review.js';
|
|
6
|
+
|
|
7
|
+
// The outbox. Every write Flow makes to another system is a row here first,
|
|
8
|
+
// written in the same transaction as the Flow change it reports, with its
|
|
9
|
+
// idempotency key. An adapter sends it through its governor and records what
|
|
10
|
+
// came back. So a write is never lost, never made twice, and never made
|
|
11
|
+
// without a record of why.
|
|
12
|
+
//
|
|
13
|
+
// pending -> sending -> sent
|
|
14
|
+
// -> ambiguous (outcome unknown: settle by a lookup first)
|
|
15
|
+
// -> pending again (a retry) or failed (opens a review item)
|
|
16
|
+
// pending or ambiguous -> cancelled (a person)
|
|
17
|
+
|
|
18
|
+
// How long a send may take before Flow treats its outcome as unknown.
|
|
19
|
+
export const SEND_LEASE_MS = 10 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
// Queues a write. Pass the transaction that makes the Flow change. A repeat with
|
|
22
|
+
// the same key returns the first row; the same key for another record refuses.
|
|
23
|
+
export async function queueWrite(tx, organisationId, {
|
|
24
|
+
system, action, entity, flowId, idempotencyKey, request, actor,
|
|
25
|
+
}) {
|
|
26
|
+
if (!system || !action || !entity || !flowId) throw new FlowError('invalid', 'system, action, entity and flowId are required');
|
|
27
|
+
if (!idempotencyKey) throw new FlowError('invalid', 'idempotencyKey is required');
|
|
28
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
29
|
+
const [row] = await tx.insert(outbox)
|
|
30
|
+
.values({ organisationId, system, action, entity, flowId, idempotencyKey, request, createdBy: actor })
|
|
31
|
+
.onConflictDoNothing({ target: outbox.idempotencyKey })
|
|
32
|
+
.returning();
|
|
33
|
+
if (row) return { write: row, queued: true };
|
|
34
|
+
const [first] = await tx.select().from(outbox).where(eq(outbox.idempotencyKey, idempotencyKey));
|
|
35
|
+
if (first.organisationId !== organisationId || first.flowId !== flowId || first.action !== action) {
|
|
36
|
+
throw new FlowError('conflict', 'idempotency key already used for another write');
|
|
37
|
+
}
|
|
38
|
+
return { write: first, queued: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Claims pending writes for one system. A write left `sending` past the lease
|
|
42
|
+
// (the process died mid-call) becomes `ambiguous`: the call may have left.
|
|
43
|
+
export async function claimWrites(db, { system, limit = 10, leaseMs = SEND_LEASE_MS }) {
|
|
44
|
+
return db.transaction(async (tx) => {
|
|
45
|
+
await tx.update(outbox)
|
|
46
|
+
.set({ state: 'ambiguous', lastError: 'send did not finish', updatedAt: sql`now()` })
|
|
47
|
+
.where(and(
|
|
48
|
+
eq(outbox.system, system),
|
|
49
|
+
eq(outbox.state, 'sending'),
|
|
50
|
+
sql`${outbox.updatedAt} < now() - make_interval(secs => ${leaseMs / 1000})`,
|
|
51
|
+
));
|
|
52
|
+
const { rows } = await tx.execute(sql`
|
|
53
|
+
select id from outbox where system = ${system} and state = 'pending'
|
|
54
|
+
order by created_at for update skip locked limit ${limit}`);
|
|
55
|
+
const claimed = [];
|
|
56
|
+
for (const { id } of rows) {
|
|
57
|
+
const [write] = await tx.update(outbox)
|
|
58
|
+
.set({ state: 'sending', attempts: sql`${outbox.attempts} + 1`, updatedAt: sql`now()` })
|
|
59
|
+
.where(eq(outbox.id, id))
|
|
60
|
+
.returning();
|
|
61
|
+
claimed.push(write);
|
|
62
|
+
}
|
|
63
|
+
return claimed;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Moves one write from the states in `from` to new fields. Refuses a move from
|
|
68
|
+
// any other state, so two workers can never both settle one write.
|
|
69
|
+
async function move(tx, organisationId, writeId, from, fields) {
|
|
70
|
+
const [row] = await tx.update(outbox)
|
|
71
|
+
.set({ ...fields, updatedAt: sql`now()` })
|
|
72
|
+
.where(and(
|
|
73
|
+
eq(outbox.organisationId, organisationId),
|
|
74
|
+
eq(outbox.id, writeId),
|
|
75
|
+
sql`${outbox.state} in (${sql.join(from.map((s) => sql`${s}`), sql`, `)})`,
|
|
76
|
+
))
|
|
77
|
+
.returning();
|
|
78
|
+
if (row) return row;
|
|
79
|
+
const [existing] = await tx.select().from(outbox)
|
|
80
|
+
.where(and(eq(outbox.organisationId, organisationId), eq(outbox.id, writeId)));
|
|
81
|
+
if (!existing) throw notFound('write');
|
|
82
|
+
throw new FlowError('conflict', `write is ${existing.state}`, { state: existing.state });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The system made the record. Flow links its own record to it in the same
|
|
86
|
+
// transaction, so the identity map always knows what Flow created.
|
|
87
|
+
export async function markSent(db, organisationId, writeId, { externalId }) {
|
|
88
|
+
if (!externalId) throw new FlowError('invalid', 'externalId is required');
|
|
89
|
+
return db.transaction(async (tx) => {
|
|
90
|
+
const row = await move(tx, organisationId, writeId, ['sending', 'ambiguous'], {
|
|
91
|
+
state: 'sent', externalId, sentAt: sql`now()`, lastError: null,
|
|
92
|
+
});
|
|
93
|
+
const link = await linkRecord(tx, organisationId, {
|
|
94
|
+
entity: row.entity, flowId: row.flowId, system: row.system, externalId, matchedBy: 'created_by_flow',
|
|
95
|
+
});
|
|
96
|
+
return { write: row, link };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The outcome is unknown (a timeout, a 5xx after the request left). The adapter
|
|
101
|
+
// must look in the system before it sends again.
|
|
102
|
+
export async function markAmbiguous(db, organisationId, writeId, { error }) {
|
|
103
|
+
return move(db, organisationId, writeId, ['sending'], { state: 'ambiguous', lastError: String(error).slice(0, 2000) });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The system refused the write, or a lookup found it was never made. With
|
|
107
|
+
// `retry` it goes back to pending; without, it fails and opens a review item.
|
|
108
|
+
export async function markFailed(db, organisationId, writeId, { error, retry = false }) {
|
|
109
|
+
const message = String(error).slice(0, 2000);
|
|
110
|
+
if (retry) return move(db, organisationId, writeId, ['sending', 'ambiguous'], { state: 'pending', lastError: message });
|
|
111
|
+
return db.transaction(async (tx) => {
|
|
112
|
+
const row = await move(tx, organisationId, writeId, ['sending', 'ambiguous'], { state: 'failed', lastError: message });
|
|
113
|
+
await openReview(tx, organisationId, {
|
|
114
|
+
kind: 'write.failed',
|
|
115
|
+
entity: row.entity,
|
|
116
|
+
flowId: row.flowId,
|
|
117
|
+
systems: [row.system],
|
|
118
|
+
evidence: { writeId: row.id, action: row.action, attempts: row.attempts, error: message },
|
|
119
|
+
});
|
|
120
|
+
return row;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function cancelWrite(db, organisationId, writeId, { actor }) {
|
|
125
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
126
|
+
return move(db, organisationId, writeId, ['pending', 'ambiguous', 'failed'], {
|
|
127
|
+
state: 'cancelled', lastError: `cancelled by ${actor}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { backgroundJobs } from '../db/schema.js';
|
|
3
|
+
import { FlowError } from './errors.js';
|
|
4
|
+
import { openReview } from './review.js';
|
|
5
|
+
|
|
6
|
+
// The background job queue (issue 6). Every sync and write step runs as a job
|
|
7
|
+
// here, so steps never overlap, a crash never loses one, and a person can see
|
|
8
|
+
// what is queued, running or stuck. It is PostgreSQL only: workers claim with
|
|
9
|
+
// FOR UPDATE SKIP LOCKED and hold a lease. A lease that runs out makes the job
|
|
10
|
+
// claimable again, so a dead process never holds a job for good.
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_LEASE_MS = 5 * 60 * 1000;
|
|
13
|
+
const FIRST_RETRY_MS = 60 * 1000;
|
|
14
|
+
const LAST_RETRY_MS = 6 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
// The wait before attempt `attempts + 1`: 1 minute, doubling, at most 6 hours.
|
|
17
|
+
export function retryDelayMs(attempts) {
|
|
18
|
+
return Math.min(FIRST_RETRY_MS * 2 ** Math.max(0, attempts - 1), LAST_RETRY_MS);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Queues a job. While a job with the same kind and dedupe key is queued or
|
|
22
|
+
// running, it is returned instead of a second one.
|
|
23
|
+
export async function enqueueJob(db, organisationId, {
|
|
24
|
+
kind, dedupeKey = '', payload = {}, runAfter = null, maxAttempts = 8,
|
|
25
|
+
}) {
|
|
26
|
+
if (!kind) throw new FlowError('invalid', 'kind is required');
|
|
27
|
+
const [row] = await db.insert(backgroundJobs)
|
|
28
|
+
.values({ organisationId, kind, dedupeKey, payload, maxAttempts, ...(runAfter ? { runAfter } : {}) })
|
|
29
|
+
.onConflictDoNothing()
|
|
30
|
+
.returning();
|
|
31
|
+
if (row) return { job: row, queued: true };
|
|
32
|
+
const [open] = await db.select().from(backgroundJobs).where(and(
|
|
33
|
+
eq(backgroundJobs.organisationId, organisationId),
|
|
34
|
+
eq(backgroundJobs.kind, kind),
|
|
35
|
+
eq(backgroundJobs.dedupeKey, dedupeKey),
|
|
36
|
+
sql`${backgroundJobs.state} in ('queued', 'running')`,
|
|
37
|
+
));
|
|
38
|
+
return { job: open, queued: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function giveUp(tx, job, error) {
|
|
42
|
+
const [row] = await tx.update(backgroundJobs)
|
|
43
|
+
.set({ state: 'dead', lockedBy: null, lockedUntil: null, lastError: error, updatedAt: sql`now()` })
|
|
44
|
+
.where(eq(backgroundJobs.id, job.id))
|
|
45
|
+
.returning();
|
|
46
|
+
await openReview(tx, job.organisationId, {
|
|
47
|
+
kind: 'job.dead',
|
|
48
|
+
entity: 'background_job',
|
|
49
|
+
flowId: job.id,
|
|
50
|
+
evidence: { kind: job.kind, dedupeKey: job.dedupeKey, attempts: job.attempts, error },
|
|
51
|
+
});
|
|
52
|
+
return row;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Claims up to `limit` due jobs for one worker. A running job whose lease ran
|
|
56
|
+
// out is due again; one that has used every attempt is given up instead.
|
|
57
|
+
export async function claimJobs(db, { workerId, kinds = null, limit = 1, leaseMs = DEFAULT_LEASE_MS }) {
|
|
58
|
+
if (!workerId) throw new FlowError('invalid', 'workerId is required');
|
|
59
|
+
return db.transaction(async (tx) => {
|
|
60
|
+
const kindFilter = kinds?.length
|
|
61
|
+
? sql`and kind in (${sql.join(kinds.map((k) => sql`${k}`), sql`, `)})`
|
|
62
|
+
: sql``;
|
|
63
|
+
const { rows: due } = await tx.execute(sql`
|
|
64
|
+
select id, attempts, max_attempts from background_jobs
|
|
65
|
+
where ((state = 'queued' and run_after <= now()) or (state = 'running' and locked_until < now()))
|
|
66
|
+
${kindFilter}
|
|
67
|
+
order by run_after, created_at
|
|
68
|
+
for update skip locked
|
|
69
|
+
limit ${limit}`);
|
|
70
|
+
const claimed = [];
|
|
71
|
+
for (const candidate of due) {
|
|
72
|
+
if (candidate.attempts >= candidate.max_attempts) {
|
|
73
|
+
const [job] = await tx.select().from(backgroundJobs).where(eq(backgroundJobs.id, candidate.id));
|
|
74
|
+
await giveUp(tx, job, job.lastError ?? 'lease ran out on the last attempt');
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const [job] = await tx.update(backgroundJobs)
|
|
78
|
+
.set({
|
|
79
|
+
state: 'running',
|
|
80
|
+
lockedBy: workerId,
|
|
81
|
+
lockedUntil: sql`now() + make_interval(secs => ${leaseMs / 1000})`,
|
|
82
|
+
attempts: sql`${backgroundJobs.attempts} + 1`,
|
|
83
|
+
updatedAt: sql`now()`,
|
|
84
|
+
})
|
|
85
|
+
.where(eq(backgroundJobs.id, candidate.id))
|
|
86
|
+
.returning();
|
|
87
|
+
claimed.push(job);
|
|
88
|
+
}
|
|
89
|
+
return claimed;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Only the worker that holds the lease may finish a job. A worker whose lease
|
|
94
|
+
// ran out and was taken over finishes nothing and is told so.
|
|
95
|
+
async function finish(db, job, workerId, fields) {
|
|
96
|
+
const [row] = await db.update(backgroundJobs)
|
|
97
|
+
.set({ ...fields, lockedBy: null, lockedUntil: null, updatedAt: sql`now()` })
|
|
98
|
+
.where(and(
|
|
99
|
+
eq(backgroundJobs.id, job.id),
|
|
100
|
+
eq(backgroundJobs.state, 'running'),
|
|
101
|
+
eq(backgroundJobs.lockedBy, workerId),
|
|
102
|
+
eq(backgroundJobs.attempts, job.attempts),
|
|
103
|
+
))
|
|
104
|
+
.returning();
|
|
105
|
+
if (!row) throw new FlowError('conflict', 'job lease was lost', { jobId: job.id });
|
|
106
|
+
return row;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function completeJob(db, job, { workerId, result = null }) {
|
|
110
|
+
return finish(db, job, workerId, { state: 'succeeded', result, lastError: null });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A failed attempt waits and runs again, until its attempts run out or the
|
|
114
|
+
// error cannot be fixed by waiting (`retry: false`); then it opens a review item.
|
|
115
|
+
export async function failJob(db, job, { workerId, error, retry = true }) {
|
|
116
|
+
const message = String(error?.message ?? error ?? 'failed').slice(0, 2000);
|
|
117
|
+
if (!retry || job.attempts >= job.maxAttempts) {
|
|
118
|
+
return db.transaction(async (tx) => {
|
|
119
|
+
const row = await finish(tx, job, workerId, { state: 'running', lastError: message });
|
|
120
|
+
return giveUp(tx, { ...row, attempts: job.attempts }, message);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return finish(db, job, workerId, {
|
|
124
|
+
state: 'queued',
|
|
125
|
+
lastError: message,
|
|
126
|
+
runAfter: sql`now() + make_interval(secs => ${retryDelayMs(job.attempts) / 1000})`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Claims one due job and runs its handler. Answers what happened, or null when
|
|
131
|
+
// nothing was due. A handler throws to fail; `error.retry = false` gives up.
|
|
132
|
+
export async function runNextJob(db, { workerId, handlers, leaseMs }) {
|
|
133
|
+
const [job] = await claimJobs(db, { workerId, kinds: Object.keys(handlers), limit: 1, leaseMs });
|
|
134
|
+
if (!job) return null;
|
|
135
|
+
try {
|
|
136
|
+
const result = await handlers[job.kind](job);
|
|
137
|
+
return { job: await completeJob(db, job, { workerId, result: result ?? null }), outcome: 'succeeded' };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const row = await failJob(db, job, { workerId, error, retry: error?.retry !== false });
|
|
140
|
+
return { job: row, outcome: row.state };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { reviewItems } from '../db/schema.js';
|
|
3
|
+
import { FlowError, notFound } from './errors.js';
|
|
4
|
+
|
|
5
|
+
// The review queue (issue 59). Everything a person must decide is one row
|
|
6
|
+
// here: a link Flow could not prove, a write that failed for good, a job that
|
|
7
|
+
// gave up, a difference between two systems. Flow never settles a conflict by
|
|
8
|
+
// overwriting one side; it opens an item and waits for a person.
|
|
9
|
+
|
|
10
|
+
function requireActor(actor) {
|
|
11
|
+
if (!actor) throw new FlowError('invalid', 'actor is required');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Opens an item, or updates the one open item for the same kind and record:
|
|
15
|
+
// a repeat finding refreshes the evidence and never makes a second item.
|
|
16
|
+
export async function openReview(db, organisationId, {
|
|
17
|
+
kind, entity, flowId, systems = [], evidence = {}, moneyAtRiskCents = 0,
|
|
18
|
+
}) {
|
|
19
|
+
if (!kind || !entity || !flowId) throw new FlowError('invalid', 'kind, entity and flowId are required');
|
|
20
|
+
if (!Number.isSafeInteger(moneyAtRiskCents) || moneyAtRiskCents < 0) {
|
|
21
|
+
throw new FlowError('invalid', 'moneyAtRiskCents must be whole, non-negative cents');
|
|
22
|
+
}
|
|
23
|
+
const [row] = await db.insert(reviewItems)
|
|
24
|
+
.values({ organisationId, kind, entity, flowId, systems, evidence, moneyAtRiskCents })
|
|
25
|
+
.onConflictDoUpdate({
|
|
26
|
+
target: [reviewItems.organisationId, reviewItems.kind, reviewItems.entity, reviewItems.flowId],
|
|
27
|
+
targetWhere: sql`state = 'open'`,
|
|
28
|
+
set: {
|
|
29
|
+
systems,
|
|
30
|
+
evidence,
|
|
31
|
+
moneyAtRiskCents,
|
|
32
|
+
seenCount: sql`${reviewItems.seenCount} + 1`,
|
|
33
|
+
updatedAt: sql`now()`,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
.returning();
|
|
37
|
+
return row;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Closes an open item with the choice a person made. `dismiss` records that
|
|
41
|
+
// nothing needed doing.
|
|
42
|
+
export async function resolveReview(db, organisationId, reviewId, { resolution, note = null, actor, dismiss = false }) {
|
|
43
|
+
requireActor(actor);
|
|
44
|
+
if (!resolution) throw new FlowError('invalid', 'resolution is required');
|
|
45
|
+
const [row] = await db.update(reviewItems)
|
|
46
|
+
.set({
|
|
47
|
+
state: dismiss ? 'dismissed' : 'resolved',
|
|
48
|
+
resolution,
|
|
49
|
+
note,
|
|
50
|
+
resolvedBy: actor,
|
|
51
|
+
resolvedAt: sql`now()`,
|
|
52
|
+
updatedAt: sql`now()`,
|
|
53
|
+
})
|
|
54
|
+
.where(and(
|
|
55
|
+
eq(reviewItems.organisationId, organisationId),
|
|
56
|
+
eq(reviewItems.id, reviewId),
|
|
57
|
+
eq(reviewItems.state, 'open'),
|
|
58
|
+
))
|
|
59
|
+
.returning();
|
|
60
|
+
if (row) return row;
|
|
61
|
+
const [existing] = await db.select().from(reviewItems)
|
|
62
|
+
.where(and(eq(reviewItems.organisationId, organisationId), eq(reviewItems.id, reviewId)));
|
|
63
|
+
if (!existing) throw notFound('review item');
|
|
64
|
+
throw new FlowError('conflict', 'review item is already closed', { state: existing.state });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Closes the open item for one record and kind, when the cause went away on its
|
|
68
|
+
// own (for example a person confirmed a link from another screen).
|
|
69
|
+
export async function resolveReviewFor(db, organisationId, { kind, entity, flowId, resolution, actor }) {
|
|
70
|
+
requireActor(actor);
|
|
71
|
+
const rows = await db.update(reviewItems)
|
|
72
|
+
.set({ state: 'resolved', resolution, resolvedBy: actor, resolvedAt: sql`now()`, updatedAt: sql`now()` })
|
|
73
|
+
.where(and(
|
|
74
|
+
eq(reviewItems.organisationId, organisationId),
|
|
75
|
+
eq(reviewItems.kind, kind),
|
|
76
|
+
eq(reviewItems.entity, entity),
|
|
77
|
+
eq(reviewItems.flowId, flowId),
|
|
78
|
+
eq(reviewItems.state, 'open'),
|
|
79
|
+
))
|
|
80
|
+
.returning();
|
|
81
|
+
return rows[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The queue a person works through: most money at risk first, then oldest.
|
|
85
|
+
export async function listReview(db, organisationId, { state = 'open', kind, limit = 100 } = {}) {
|
|
86
|
+
const filters = [eq(reviewItems.organisationId, organisationId), eq(reviewItems.state, state)];
|
|
87
|
+
if (kind) filters.push(eq(reviewItems.kind, kind));
|
|
88
|
+
return db.select().from(reviewItems)
|
|
89
|
+
.where(and(...filters))
|
|
90
|
+
.orderBy(desc(reviewItems.moneyAtRiskCents), asc(reviewItems.createdAt))
|
|
91
|
+
.limit(limit);
|
|
92
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
CREATE TABLE "background_jobs" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
+
"organisation_id" uuid NOT NULL,
|
|
4
|
+
"kind" text NOT NULL,
|
|
5
|
+
"dedupe_key" text DEFAULT '' NOT NULL,
|
|
6
|
+
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
7
|
+
"state" text DEFAULT 'queued' NOT NULL,
|
|
8
|
+
"run_after" timestamp with time zone DEFAULT now() NOT NULL,
|
|
9
|
+
"attempts" integer DEFAULT 0 NOT NULL,
|
|
10
|
+
"max_attempts" integer DEFAULT 8 NOT NULL,
|
|
11
|
+
"locked_by" text,
|
|
12
|
+
"locked_until" timestamp with time zone,
|
|
13
|
+
"result" jsonb,
|
|
14
|
+
"last_error" text,
|
|
15
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
16
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
17
|
+
CONSTRAINT "background_jobs_state_valid" CHECK (state in ('queued', 'running', 'succeeded', 'failed', 'dead')),
|
|
18
|
+
CONSTRAINT "background_jobs_attempts_valid" CHECK ("background_jobs"."attempts" >= 0 and "background_jobs"."max_attempts" > 0)
|
|
19
|
+
);
|
|
20
|
+
--> statement-breakpoint
|
|
21
|
+
CREATE TABLE "external_links" (
|
|
22
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
23
|
+
"organisation_id" uuid NOT NULL,
|
|
24
|
+
"entity" text NOT NULL,
|
|
25
|
+
"flow_id" uuid NOT NULL,
|
|
26
|
+
"system" text NOT NULL,
|
|
27
|
+
"external_id" text NOT NULL,
|
|
28
|
+
"matched_by" text NOT NULL,
|
|
29
|
+
"state" text NOT NULL,
|
|
30
|
+
"confirmed_by" uuid,
|
|
31
|
+
"confirmed_at" timestamp with time zone,
|
|
32
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
33
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
34
|
+
CONSTRAINT "external_links_org_id" UNIQUE("organisation_id","id"),
|
|
35
|
+
CONSTRAINT "external_links_entity_valid" CHECK (entity in ('contact', 'price_item', 'obligation', 'invoice', 'payment', 'credit_note')),
|
|
36
|
+
CONSTRAINT "external_links_matched_by_valid" CHECK (matched_by in ('created_by_flow', 'exact_id', 'imported', 'name', 'person')),
|
|
37
|
+
CONSTRAINT "external_links_state_valid" CHECK (state in ('active', 'proposed', 'superseded')),
|
|
38
|
+
CONSTRAINT "external_links_name_needs_person" CHECK ("external_links"."matched_by" <> 'name' or "external_links"."state" <> 'active' or "external_links"."confirmed_at" is not null)
|
|
39
|
+
);
|
|
40
|
+
--> statement-breakpoint
|
|
41
|
+
CREATE TABLE "outbox" (
|
|
42
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
43
|
+
"organisation_id" uuid NOT NULL,
|
|
44
|
+
"system" text NOT NULL,
|
|
45
|
+
"action" text NOT NULL,
|
|
46
|
+
"entity" text NOT NULL,
|
|
47
|
+
"flow_id" uuid NOT NULL,
|
|
48
|
+
"idempotency_key" text NOT NULL,
|
|
49
|
+
"request" jsonb NOT NULL,
|
|
50
|
+
"state" text DEFAULT 'pending' NOT NULL,
|
|
51
|
+
"external_id" text,
|
|
52
|
+
"attempts" integer DEFAULT 0 NOT NULL,
|
|
53
|
+
"last_error" text,
|
|
54
|
+
"created_by" text NOT NULL,
|
|
55
|
+
"sent_at" timestamp with time zone,
|
|
56
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
57
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
58
|
+
CONSTRAINT "outbox_org_id" UNIQUE("organisation_id","id"),
|
|
59
|
+
CONSTRAINT "outbox_idempotency_key" UNIQUE("idempotency_key"),
|
|
60
|
+
CONSTRAINT "outbox_state_valid" CHECK (state in ('pending', 'sending', 'ambiguous', 'sent', 'failed', 'cancelled')),
|
|
61
|
+
CONSTRAINT "outbox_sent_has_id" CHECK ("outbox"."state" <> 'sent' or "outbox"."external_id" is not null)
|
|
62
|
+
);
|
|
63
|
+
--> statement-breakpoint
|
|
64
|
+
CREATE TABLE "review_items" (
|
|
65
|
+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
66
|
+
"organisation_id" uuid NOT NULL,
|
|
67
|
+
"kind" text NOT NULL,
|
|
68
|
+
"entity" text NOT NULL,
|
|
69
|
+
"flow_id" uuid NOT NULL,
|
|
70
|
+
"systems" text[] DEFAULT '{}'::text[] NOT NULL,
|
|
71
|
+
"evidence" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
|
72
|
+
"money_at_risk_cents" bigint DEFAULT 0 NOT NULL,
|
|
73
|
+
"state" text DEFAULT 'open' NOT NULL,
|
|
74
|
+
"seen_count" integer DEFAULT 1 NOT NULL,
|
|
75
|
+
"resolution" text,
|
|
76
|
+
"note" text,
|
|
77
|
+
"resolved_by" text,
|
|
78
|
+
"resolved_at" timestamp with time zone,
|
|
79
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
80
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
81
|
+
CONSTRAINT "review_items_org_id" UNIQUE("organisation_id","id"),
|
|
82
|
+
CONSTRAINT "review_items_state_valid" CHECK (state in ('open', 'resolved', 'dismissed')),
|
|
83
|
+
CONSTRAINT "review_items_money_not_negative" CHECK ("review_items"."money_at_risk_cents" >= 0),
|
|
84
|
+
CONSTRAINT "review_items_closed_has_resolution" CHECK ("review_items"."state" = 'open' or ("review_items"."resolution" is not null and "review_items"."resolved_by" is not null and "review_items"."resolved_at" is not null))
|
|
85
|
+
);
|
|
86
|
+
--> statement-breakpoint
|
|
87
|
+
ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
88
|
+
ALTER TABLE "external_links" ADD CONSTRAINT "external_links_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
89
|
+
ALTER TABLE "external_links" ADD CONSTRAINT "external_links_confirmed_by_people_id_fk" FOREIGN KEY ("confirmed_by") REFERENCES "public"."people"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
|
90
|
+
ALTER TABLE "outbox" ADD CONSTRAINT "outbox_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
91
|
+
ALTER TABLE "review_items" ADD CONSTRAINT "review_items_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
|
92
|
+
CREATE UNIQUE INDEX "background_jobs_one_open" ON "background_jobs" USING btree ("organisation_id","kind","dedupe_key") WHERE state in ('queued', 'running');--> statement-breakpoint
|
|
93
|
+
CREATE INDEX "background_jobs_due" ON "background_jobs" USING btree ("state","run_after");--> statement-breakpoint
|
|
94
|
+
CREATE UNIQUE INDEX "external_links_active_external" ON "external_links" USING btree ("organisation_id","system","entity","external_id") WHERE state = 'active';--> statement-breakpoint
|
|
95
|
+
CREATE UNIQUE INDEX "external_links_active_flow" ON "external_links" USING btree ("organisation_id","system","entity","flow_id") WHERE state = 'active';--> statement-breakpoint
|
|
96
|
+
CREATE INDEX "external_links_flow" ON "external_links" USING btree ("organisation_id","entity","flow_id");--> statement-breakpoint
|
|
97
|
+
CREATE INDEX "outbox_pending" ON "outbox" USING btree ("system","state","created_at");--> statement-breakpoint
|
|
98
|
+
CREATE UNIQUE INDEX "review_items_one_open" ON "review_items" USING btree ("organisation_id","kind","entity","flow_id") WHERE state = 'open';--> statement-breakpoint
|
|
99
|
+
CREATE INDEX "review_items_queue" ON "review_items" USING btree ("organisation_id","state","money_at_risk_cents");
|