@krizaka/outbox 0.0.0-stage → 0.1.0
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/LICENSE +201 -0
- package/README.md +38 -2
- package/dist/chunk-7WIJNAGK.js +24 -0
- package/dist/chunk-7WIJNAGK.js.map +1 -0
- package/dist/chunk-AD76AB44.js +44 -0
- package/dist/chunk-AD76AB44.js.map +1 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +310 -0
- package/dist/index.js.map +1 -0
- package/dist/port-CXch5iMa.d.ts +124 -0
- package/dist/settings.d.ts +22 -0
- package/dist/settings.js +33 -0
- package/dist/settings.js.map +1 -0
- package/dist/testkit.d.ts +6 -0
- package/dist/testkit.js +139 -0
- package/dist/testkit.js.map +1 -0
- package/package.json +81 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OUTBOX_BOUNDS,
|
|
3
|
+
OUTBOX_DEFAULTS,
|
|
4
|
+
backoffSeconds,
|
|
5
|
+
checkMessage,
|
|
6
|
+
checkSettings
|
|
7
|
+
} from "./chunk-AD76AB44.js";
|
|
8
|
+
import {
|
|
9
|
+
FAIL_STATE,
|
|
10
|
+
OUTBOX_CODE,
|
|
11
|
+
OutboxError
|
|
12
|
+
} from "./chunk-7WIJNAGK.js";
|
|
13
|
+
|
|
14
|
+
// src/consumer.ts
|
|
15
|
+
import { ident } from "@krizaka/db";
|
|
16
|
+
async function consumeOnce(tx, consumer, messageId, now, effect, schema = "outbox") {
|
|
17
|
+
const rows = await tx(`INSERT INTO ${ident(schema)}.processed (consumer, message_id, processed_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING 1`, [consumer, messageId, now]);
|
|
18
|
+
if (rows.length === 0) return "duplicate";
|
|
19
|
+
await effect();
|
|
20
|
+
return "applied";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/memory.ts
|
|
24
|
+
import { addSeconds, isAfter, isBefore } from "@krizaka/time";
|
|
25
|
+
function memoryOutboxStore() {
|
|
26
|
+
const rows = /* @__PURE__ */ new Map();
|
|
27
|
+
const open = (r) => r.deliveredAt === null && r.deadAt === null;
|
|
28
|
+
const leaseValid = (r, now) => r.leasedUntil !== null && isAfter(r.leasedUntil, now);
|
|
29
|
+
return {
|
|
30
|
+
async append(m) {
|
|
31
|
+
if (rows.has(m.id)) return false;
|
|
32
|
+
rows.set(m.id, { id: m.id, topic: m.topic, type: m.type, payload: structuredClone(m.payload), createdAt: m.createdAt, availableAt: m.availableAt, attempts: 0, leasedUntil: null, leaseOwner: null, deliveredAt: null, deadAt: null, lastError: null });
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
async claim({ topic, owner, limit, leaseSeconds, maxAttempts, now }) {
|
|
36
|
+
for (const r of rows.values()) {
|
|
37
|
+
if (open(r) && r.attempts >= maxAttempts && r.leasedUntil !== null && !leaseValid(r, now)) {
|
|
38
|
+
r.deadAt = now;
|
|
39
|
+
r.leasedUntil = null;
|
|
40
|
+
r.leaseOwner = null;
|
|
41
|
+
r.lastError ??= "the last attempt's lease expired with no answer";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const due = [...rows.values()].filter((r) => open(r) && r.attempts < maxAttempts && !isAfter(r.availableAt, now) && !leaseValid(r, now) && (topic === void 0 || r.topic === topic)).sort((a, b) => a.availableAt.valueOf() - b.availableAt.valueOf() || a.createdAt.valueOf() - b.createdAt.valueOf()).slice(0, limit);
|
|
45
|
+
return due.map((r) => {
|
|
46
|
+
r.attempts += 1;
|
|
47
|
+
r.leasedUntil = addSeconds(now, leaseSeconds);
|
|
48
|
+
r.leaseOwner = owner;
|
|
49
|
+
return { id: r.id, topic: r.topic, type: r.type, payload: structuredClone(r.payload), occurredAt: r.createdAt, attempts: r.attempts };
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
async complete(id, owner, now) {
|
|
53
|
+
const r = rows.get(id);
|
|
54
|
+
if (!r || !open(r) || r.leaseOwner !== owner) return false;
|
|
55
|
+
r.deliveredAt = now;
|
|
56
|
+
r.leasedUntil = null;
|
|
57
|
+
r.leaseOwner = null;
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
async fail(id, owner, error, { now, maxAttempts, backoffSeconds: base, backoffMaxSeconds }) {
|
|
61
|
+
const r = rows.get(id);
|
|
62
|
+
if (!r || !open(r) || r.leaseOwner !== owner) return { state: FAIL_STATE.lost };
|
|
63
|
+
r.lastError = error;
|
|
64
|
+
r.leasedUntil = null;
|
|
65
|
+
r.leaseOwner = null;
|
|
66
|
+
if (r.attempts >= maxAttempts) {
|
|
67
|
+
r.deadAt = now;
|
|
68
|
+
return { state: FAIL_STATE.dead };
|
|
69
|
+
}
|
|
70
|
+
r.availableAt = addSeconds(now, backoffSeconds(r.attempts, { backoffSeconds: base, backoffMaxSeconds }));
|
|
71
|
+
return { state: FAIL_STATE.retry, availableAt: r.availableAt };
|
|
72
|
+
},
|
|
73
|
+
async retryDead(id, now) {
|
|
74
|
+
const r = rows.get(id);
|
|
75
|
+
if (!r || r.deadAt === null) return false;
|
|
76
|
+
r.deadAt = null;
|
|
77
|
+
r.attempts = 0;
|
|
78
|
+
r.availableAt = now;
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
async purgeBefore(before) {
|
|
82
|
+
let removed = 0;
|
|
83
|
+
for (const [id, r] of rows) {
|
|
84
|
+
const finished = r.deliveredAt ?? r.deadAt;
|
|
85
|
+
if (finished !== null && isBefore(finished, before)) {
|
|
86
|
+
rows.delete(id);
|
|
87
|
+
removed += 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return removed;
|
|
91
|
+
},
|
|
92
|
+
async stats(now) {
|
|
93
|
+
const all = [...rows.values()];
|
|
94
|
+
return {
|
|
95
|
+
pending: all.filter((r) => open(r) && !leaseValid(r, now)).length,
|
|
96
|
+
leased: all.filter((r) => open(r) && leaseValid(r, now)).length,
|
|
97
|
+
delivered: all.filter((r) => r.deliveredAt !== null).length,
|
|
98
|
+
dead: all.filter((r) => r.deadAt !== null).length
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
rows: () => [...rows.values()]
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/outbox.ts
|
|
106
|
+
import { randomUUID } from "crypto";
|
|
107
|
+
import { ident as ident3 } from "@krizaka/db";
|
|
108
|
+
import { subHours } from "@krizaka/time";
|
|
109
|
+
|
|
110
|
+
// src/persistence.ts
|
|
111
|
+
import { ident as ident2 } from "@krizaka/db";
|
|
112
|
+
import { addSeconds as addSeconds2 } from "@krizaka/time";
|
|
113
|
+
function dbOutboxStore(query, schema = "outbox") {
|
|
114
|
+
const message = `${ident2(schema)}.message`;
|
|
115
|
+
return {
|
|
116
|
+
async append(m) {
|
|
117
|
+
const rows = await query(
|
|
118
|
+
`INSERT INTO ${message} (id, topic, event_type, payload, created_at, available_at) VALUES ($1, $2, $3, $4::jsonb, $5, $6) ON CONFLICT (id) DO NOTHING RETURNING 1`,
|
|
119
|
+
[m.id, m.topic, m.type, JSON.stringify(m.payload), m.createdAt, m.availableAt]
|
|
120
|
+
);
|
|
121
|
+
return rows.length > 0;
|
|
122
|
+
},
|
|
123
|
+
async claim({ topic, owner, limit, leaseSeconds, maxAttempts, now }) {
|
|
124
|
+
await query(
|
|
125
|
+
`UPDATE ${message} SET dead_at = $1, leased_until = NULL, lease_owner = NULL, last_error = COALESCE(last_error, 'the last attempt''s lease expired with no answer')
|
|
126
|
+
WHERE delivered_at IS NULL AND dead_at IS NULL AND attempts >= $2 AND leased_until IS NOT NULL AND leased_until <= $1`,
|
|
127
|
+
[now, maxAttempts]
|
|
128
|
+
);
|
|
129
|
+
const rows = await query(
|
|
130
|
+
`UPDATE ${message} AS m SET leased_until = $1, lease_owner = $2, attempts = m.attempts + 1
|
|
131
|
+
WHERE m.id IN (
|
|
132
|
+
SELECT id FROM ${message}
|
|
133
|
+
WHERE delivered_at IS NULL AND dead_at IS NULL AND available_at <= $3 AND attempts < $4
|
|
134
|
+
AND (leased_until IS NULL OR leased_until <= $3) AND ($5::text IS NULL OR topic = $5)
|
|
135
|
+
ORDER BY available_at, created_at LIMIT $6
|
|
136
|
+
FOR UPDATE SKIP LOCKED)
|
|
137
|
+
RETURNING m.id, m.topic, m.event_type, m.payload, m.created_at, m.attempts`,
|
|
138
|
+
[addSeconds2(now, leaseSeconds), owner, now, maxAttempts, topic ?? null, limit]
|
|
139
|
+
);
|
|
140
|
+
return rows.map((r) => ({ id: r.id, topic: r.topic, type: r.event_type, payload: r.payload, occurredAt: new Date(r.created_at), attempts: Number(r.attempts) })).sort((a, b) => a.occurredAt.valueOf() - b.occurredAt.valueOf());
|
|
141
|
+
},
|
|
142
|
+
async complete(id, owner, now) {
|
|
143
|
+
const rows = await query(
|
|
144
|
+
`UPDATE ${message} SET delivered_at = $3, leased_until = NULL, lease_owner = NULL
|
|
145
|
+
WHERE id = $1 AND lease_owner = $2 AND delivered_at IS NULL AND dead_at IS NULL RETURNING 1`,
|
|
146
|
+
[id, owner, now]
|
|
147
|
+
);
|
|
148
|
+
return rows.length > 0;
|
|
149
|
+
},
|
|
150
|
+
async fail(id, owner, error, { now, maxAttempts, backoffSeconds: backoffSeconds2, backoffMaxSeconds }) {
|
|
151
|
+
const [row] = await query(
|
|
152
|
+
`UPDATE ${message} SET last_error = $3::text, leased_until = NULL, lease_owner = NULL,
|
|
153
|
+
dead_at = CASE WHEN attempts >= $5 THEN $4::timestamptz ELSE NULL END,
|
|
154
|
+
available_at = CASE WHEN attempts >= $5 THEN available_at
|
|
155
|
+
ELSE $4::timestamptz + make_interval(secs => LEAST($7::double precision, $6::double precision * power(2, attempts - 1))) END
|
|
156
|
+
WHERE id = $1 AND lease_owner = $2 AND delivered_at IS NULL AND dead_at IS NULL
|
|
157
|
+
RETURNING attempts, dead_at, available_at`,
|
|
158
|
+
[id, owner, error, now, maxAttempts, backoffSeconds2, backoffMaxSeconds]
|
|
159
|
+
);
|
|
160
|
+
if (!row) return { state: FAIL_STATE.lost };
|
|
161
|
+
return row.dead_at ? { state: FAIL_STATE.dead } : { state: FAIL_STATE.retry, availableAt: new Date(row.available_at) };
|
|
162
|
+
},
|
|
163
|
+
async retryDead(id, now) {
|
|
164
|
+
const rows = await query(`UPDATE ${message} SET dead_at = NULL, attempts = 0, available_at = $2 WHERE id = $1 AND dead_at IS NOT NULL RETURNING 1`, [id, now]);
|
|
165
|
+
return rows.length > 0;
|
|
166
|
+
},
|
|
167
|
+
async purgeBefore(before) {
|
|
168
|
+
const rows = await query(`DELETE FROM ${message} WHERE COALESCE(delivered_at, dead_at) < $1 RETURNING 1`, [before]);
|
|
169
|
+
return rows.length;
|
|
170
|
+
},
|
|
171
|
+
async stats(now) {
|
|
172
|
+
const [row] = await query(
|
|
173
|
+
`SELECT count(*) FILTER (WHERE delivered_at IS NULL AND dead_at IS NULL AND (leased_until IS NULL OR leased_until <= $1))::int AS pending,
|
|
174
|
+
count(*) FILTER (WHERE delivered_at IS NULL AND dead_at IS NULL AND leased_until > $1)::int AS leased,
|
|
175
|
+
count(*) FILTER (WHERE delivered_at IS NOT NULL)::int AS delivered,
|
|
176
|
+
count(*) FILTER (WHERE dead_at IS NOT NULL)::int AS dead
|
|
177
|
+
FROM ${message}`,
|
|
178
|
+
[now]
|
|
179
|
+
);
|
|
180
|
+
return { pending: row?.pending ?? 0, leased: row?.leased ?? 0, delivered: row?.delivered ?? 0, dead: row?.dead ?? 0 };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/outbox.ts
|
|
186
|
+
var MAX_ERROR = 1e3;
|
|
187
|
+
var describe = (error) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).slice(0, MAX_ERROR);
|
|
188
|
+
function createOutbox(deps) {
|
|
189
|
+
const schema = ident3(deps.schema ?? "outbox");
|
|
190
|
+
const storeFor = deps.storeFor ?? ((query) => dbOutboxStore(query, schema));
|
|
191
|
+
const settings = async () => checkSettings(typeof deps.settings === "function" ? await deps.settings() : deps.settings);
|
|
192
|
+
return {
|
|
193
|
+
async append(tx, message) {
|
|
194
|
+
checkMessage(message);
|
|
195
|
+
const now = deps.clock.now();
|
|
196
|
+
const id = message.id ?? randomUUID();
|
|
197
|
+
const appended = await storeFor(tx).append({ id, topic: message.topic, type: message.type, payload: message.payload, createdAt: now, availableAt: message.availableAt ?? now });
|
|
198
|
+
return { id, appended };
|
|
199
|
+
},
|
|
200
|
+
async claim(query, options) {
|
|
201
|
+
const s = await settings();
|
|
202
|
+
return storeFor(query).claim({ ...options, limit: options.limit ?? s.batchSize, leaseSeconds: options.leaseSeconds ?? s.leaseSeconds, maxAttempts: s.maxAttempts, now: deps.clock.now() });
|
|
203
|
+
},
|
|
204
|
+
complete: (query, id, owner) => storeFor(query).complete(id, owner, deps.clock.now()),
|
|
205
|
+
async fail(query, id, owner, error) {
|
|
206
|
+
const s = await settings();
|
|
207
|
+
return storeFor(query).fail(id, owner, describe(error), { now: deps.clock.now(), maxAttempts: s.maxAttempts, backoffSeconds: s.backoffSeconds, backoffMaxSeconds: s.backoffMaxSeconds });
|
|
208
|
+
},
|
|
209
|
+
retryDead: (query, id) => storeFor(query).retryDead(id, deps.clock.now()),
|
|
210
|
+
async purge(query) {
|
|
211
|
+
const s = await settings();
|
|
212
|
+
return storeFor(query).purgeBefore(subHours(deps.clock.now(), s.retentionDays * 24));
|
|
213
|
+
},
|
|
214
|
+
stats: (query) => storeFor(query).stats(deps.clock.now())
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/schema.ts
|
|
219
|
+
import { defineModule } from "@krizaka/db";
|
|
220
|
+
var outboxSchema = defineModule({
|
|
221
|
+
module: "outbox",
|
|
222
|
+
migrations: [
|
|
223
|
+
{
|
|
224
|
+
version: 1,
|
|
225
|
+
name: "messages and processed",
|
|
226
|
+
sql: (c) => `
|
|
227
|
+
CREATE TABLE ${c.schema}.message (
|
|
228
|
+
id uuid PRIMARY KEY,
|
|
229
|
+
topic text NOT NULL CHECK (topic ~ '^[a-z][a-z0-9_.:-]{0,59}$'),
|
|
230
|
+
event_type text NOT NULL CHECK (event_type ~ '^[a-z][a-z0-9_.-]{0,99}$'),
|
|
231
|
+
payload jsonb NOT NULL,
|
|
232
|
+
created_at timestamptz NOT NULL,
|
|
233
|
+
available_at timestamptz NOT NULL,
|
|
234
|
+
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
|
235
|
+
leased_until timestamptz,
|
|
236
|
+
lease_owner text,
|
|
237
|
+
delivered_at timestamptz,
|
|
238
|
+
dead_at timestamptz,
|
|
239
|
+
last_error text,
|
|
240
|
+
CHECK (delivered_at IS NULL OR dead_at IS NULL)
|
|
241
|
+
);
|
|
242
|
+
CREATE INDEX message_due ON ${c.schema}.message (topic, available_at) WHERE delivered_at IS NULL AND dead_at IS NULL;
|
|
243
|
+
CREATE INDEX message_finished ON ${c.schema}.message (COALESCE(delivered_at, dead_at)) WHERE delivered_at IS NOT NULL OR dead_at IS NOT NULL;
|
|
244
|
+
CREATE TABLE ${c.schema}.processed (
|
|
245
|
+
consumer text NOT NULL CHECK (char_length(consumer) BETWEEN 1 AND 100),
|
|
246
|
+
message_id uuid NOT NULL,
|
|
247
|
+
processed_at timestamptz NOT NULL,
|
|
248
|
+
PRIMARY KEY (consumer, message_id)
|
|
249
|
+
);`
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// src/worker.ts
|
|
255
|
+
async function drainOnce(options) {
|
|
256
|
+
const { outbox, query, owner, deliver, topic } = options;
|
|
257
|
+
const summary = { claimed: 0, delivered: 0, retried: 0, dead: 0, lost: 0 };
|
|
258
|
+
const batch = await outbox.claim(query, { owner, ...topic === void 0 ? {} : { topic } });
|
|
259
|
+
summary.claimed = batch.length;
|
|
260
|
+
for (const message of batch) {
|
|
261
|
+
let failure = null;
|
|
262
|
+
try {
|
|
263
|
+
await deliver(message);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
failure = { error };
|
|
266
|
+
}
|
|
267
|
+
if (failure === null) {
|
|
268
|
+
if (await outbox.complete(query, message.id, owner)) summary.delivered += 1;
|
|
269
|
+
else summary.lost += 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const outcome = await outbox.fail(query, message.id, owner, failure.error);
|
|
273
|
+
if (outcome.state === FAIL_STATE.retry) summary.retried += 1;
|
|
274
|
+
else if (outcome.state === FAIL_STATE.dead) summary.dead += 1;
|
|
275
|
+
else summary.lost += 1;
|
|
276
|
+
}
|
|
277
|
+
return summary;
|
|
278
|
+
}
|
|
279
|
+
var IDLE_SECONDS = 5;
|
|
280
|
+
async function runWorker(options) {
|
|
281
|
+
const idle = options.idleSeconds ?? IDLE_SECONDS;
|
|
282
|
+
while (!options.signal?.aborted) {
|
|
283
|
+
let waited;
|
|
284
|
+
try {
|
|
285
|
+
waited = (await drainOnce(options)).claimed === 0;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
options.onError?.(error);
|
|
288
|
+
waited = true;
|
|
289
|
+
}
|
|
290
|
+
if (waited && !options.signal?.aborted) await options.sleep(idle);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
export {
|
|
294
|
+
FAIL_STATE,
|
|
295
|
+
OUTBOX_BOUNDS,
|
|
296
|
+
OUTBOX_CODE,
|
|
297
|
+
OUTBOX_DEFAULTS,
|
|
298
|
+
OutboxError,
|
|
299
|
+
backoffSeconds,
|
|
300
|
+
checkMessage,
|
|
301
|
+
checkSettings,
|
|
302
|
+
consumeOnce,
|
|
303
|
+
createOutbox,
|
|
304
|
+
dbOutboxStore,
|
|
305
|
+
drainOnce,
|
|
306
|
+
memoryOutboxStore,
|
|
307
|
+
outboxSchema,
|
|
308
|
+
runWorker
|
|
309
|
+
};
|
|
310
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/consumer.ts","../src/memory.ts","../src/outbox.ts","../src/persistence.ts","../src/schema.ts","../src/worker.ts"],"sourcesContent":["import { ident, type Query } from \"@krizaka/db\";\n\n/**\n * The idempotent consumer: the outbox delivers AT LEAST ONCE, so a consumer that has an effect in the database applies it once per\n * message id. Run it inside the transaction of the effect: the marker and the effect commit together, so a crash between them\n * leaves neither and the redelivery applies both; a redelivery after the commit finds the marker and does nothing.\n *\n * ```ts\n * await db.transaction(async (tx) => {\n * const result = await consumeOnce(tx, \"credits\", message.id, clock.now(), () => credits.grant(tx, …));\n * });\n * ```\n * @param tx the transaction the effect writes through\n * @param consumer who applies it (`credits`, `mail`): a message may be consumed once by each consumer\n * @param messageId the outbox message's id\n * @param effect what to do the first time; its failure rolls the marker back with the transaction\n * @param schema the Postgres schema of the module, default `outbox`\n * @returns `applied` the first time, `duplicate` after\n */\nexport async function consumeOnce(tx: Query, consumer: string, messageId: string, now: Date, effect: () => Promise<void>, schema = \"outbox\"): Promise<\"applied\" | \"duplicate\"> {\n const rows = await tx(`INSERT INTO ${ident(schema)}.processed (consumer, message_id, processed_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING 1`, [consumer, messageId, now]);\n if (rows.length === 0) return \"duplicate\";\n await effect();\n return \"applied\";\n}\n","import { addSeconds, isAfter, isBefore } from \"@krizaka/time\";\n\nimport { FAIL_STATE, type FailOutcome, type OutboxMessage, type OutboxStats, type OutboxStore } from \"./port\";\nimport { backoffSeconds } from \"./rules\";\n\ninterface Row {\n id: string;\n topic: string;\n type: string;\n payload: unknown;\n createdAt: Date;\n availableAt: Date;\n attempts: number;\n leasedUntil: Date | null;\n leaseOwner: string | null;\n deliveredAt: Date | null;\n deadAt: Date | null;\n lastError: string | null;\n}\n\n/** An in-memory outbox store, for unit tests and demos (the same behaviour as the database's: the contract kit runs on both). */\nexport function memoryOutboxStore(): OutboxStore & { rows(): readonly Readonly<Row>[] } {\n const rows = new Map<string, Row>();\n const open = (r: Row) => r.deliveredAt === null && r.deadAt === null;\n const leaseValid = (r: Row, now: Date) => r.leasedUntil !== null && isAfter(r.leasedUntil, now);\n return {\n async append(m) {\n if (rows.has(m.id)) return false;\n rows.set(m.id, { id: m.id, topic: m.topic, type: m.type, payload: structuredClone(m.payload), createdAt: m.createdAt, availableAt: m.availableAt, attempts: 0, leasedUntil: null, leaseOwner: null, deliveredAt: null, deadAt: null, lastError: null });\n return true;\n },\n async claim({ topic, owner, limit, leaseSeconds, maxAttempts, now }) {\n for (const r of rows.values()) {\n if (open(r) && r.attempts >= maxAttempts && r.leasedUntil !== null && !leaseValid(r, now)) {\n r.deadAt = now;\n r.leasedUntil = null;\n r.leaseOwner = null;\n r.lastError ??= \"the last attempt's lease expired with no answer\";\n }\n }\n const due = [...rows.values()]\n .filter((r) => open(r) && r.attempts < maxAttempts && !isAfter(r.availableAt, now) && !leaseValid(r, now) && (topic === undefined || r.topic === topic))\n .sort((a, b) => a.availableAt.valueOf() - b.availableAt.valueOf() || a.createdAt.valueOf() - b.createdAt.valueOf())\n .slice(0, limit);\n return due.map((r): OutboxMessage => {\n r.attempts += 1;\n r.leasedUntil = addSeconds(now, leaseSeconds);\n r.leaseOwner = owner;\n return { id: r.id, topic: r.topic, type: r.type, payload: structuredClone(r.payload), occurredAt: r.createdAt, attempts: r.attempts };\n });\n },\n async complete(id, owner, now) {\n const r = rows.get(id);\n if (!r || !open(r) || r.leaseOwner !== owner) return false;\n r.deliveredAt = now;\n r.leasedUntil = null;\n r.leaseOwner = null;\n return true;\n },\n async fail(id, owner, error, { now, maxAttempts, backoffSeconds: base, backoffMaxSeconds }): Promise<FailOutcome> {\n const r = rows.get(id);\n if (!r || !open(r) || r.leaseOwner !== owner) return { state: FAIL_STATE.lost };\n r.lastError = error;\n r.leasedUntil = null;\n r.leaseOwner = null;\n if (r.attempts >= maxAttempts) {\n r.deadAt = now;\n return { state: FAIL_STATE.dead };\n }\n r.availableAt = addSeconds(now, backoffSeconds(r.attempts, { backoffSeconds: base, backoffMaxSeconds }));\n return { state: FAIL_STATE.retry, availableAt: r.availableAt };\n },\n async retryDead(id, now) {\n const r = rows.get(id);\n if (!r || r.deadAt === null) return false;\n r.deadAt = null;\n r.attempts = 0;\n r.availableAt = now;\n return true;\n },\n async purgeBefore(before) {\n let removed = 0;\n for (const [id, r] of rows) {\n const finished = r.deliveredAt ?? r.deadAt;\n if (finished !== null && isBefore(finished, before)) {\n rows.delete(id);\n removed += 1;\n }\n }\n return removed;\n },\n async stats(now): Promise<OutboxStats> {\n const all = [...rows.values()];\n return {\n pending: all.filter((r) => open(r) && !leaseValid(r, now)).length,\n leased: all.filter((r) => open(r) && leaseValid(r, now)).length,\n delivered: all.filter((r) => r.deliveredAt !== null).length,\n dead: all.filter((r) => r.deadAt !== null).length,\n };\n },\n rows: () => [...rows.values()],\n };\n}\n","import { randomUUID } from \"node:crypto\";\n\nimport { ident } from \"@krizaka/db\";\nimport { subHours } from \"@krizaka/time\";\n\nimport { dbOutboxStore } from \"./persistence\";\nimport { type ClaimOptions, type Clock, type FailOutcome, type NewMessage, type OutboxMessage, type OutboxSettings, type OutboxStats, type OutboxStore, type Query } from \"./port\";\nimport { checkMessage, checkSettings } from \"./rules\";\n\nexport interface OutboxDeps {\n clock: Clock;\n /** The settings, read at each call so the admin's change applies at once: an object, or a function over the settings service. */\n settings: OutboxSettings | (() => OutboxSettings | Promise<OutboxSettings>);\n /** The store over a query (a pool or a transaction). Default: the PostgreSQL store on schema `outbox`. */\n storeFor?: (query: Query) => OutboxStore;\n /** The Postgres schema, when the product renamed the module's (used by the default store). */\n schema?: string;\n}\n\n/** The outbox as a product uses it. Every function takes the `Query` it works through: pass `tx` to join a unit of work. */\nexport interface Outbox {\n /** Appends a message INSIDE the caller's transaction: it commits or rolls back with it. Answers its id, and `false` when that id was already appended. */\n append(tx: Query, message: NewMessage): Promise<{ id: string; appended: boolean }>;\n /** Leases a batch of due messages to `owner`. Exclusive while each lease is valid. */\n claim(query: Query, options: ClaimOptions & { limit?: number; leaseSeconds?: number }): Promise<OutboxMessage[]>;\n /** The delivery succeeded. `false` when the lease was lost to another worker. */\n complete(query: Query, id: string, owner: string): Promise<boolean>;\n /** The delivery failed: back off (doubling, capped), or dead after `maxAttempts`. */\n fail(query: Query, id: string, owner: string, error: unknown): Promise<FailOutcome>;\n /** A dead message gets another life, due now. */\n retryDead(query: Query, id: string): Promise<boolean>;\n /** Removes delivered and dead messages older than the retention. Returns how many. */\n purge(query: Query): Promise<number>;\n stats(query: Query): Promise<OutboxStats>;\n}\n\nconst MAX_ERROR = 1000;\nconst describe = (error: unknown) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error)).slice(0, MAX_ERROR);\n\n/**\n * The outbox of a product.\n * @param deps the clock, the settings, and optionally the store and schema\n * @returns the outbox's functions\n */\nexport function createOutbox(deps: OutboxDeps): Outbox {\n const schema = ident(deps.schema ?? \"outbox\");\n const storeFor = deps.storeFor ?? ((query: Query) => dbOutboxStore(query, schema));\n const settings = async () => checkSettings(typeof deps.settings === \"function\" ? await deps.settings() : deps.settings);\n return {\n async append(tx, message) {\n checkMessage(message);\n const now = deps.clock.now();\n const id = message.id ?? randomUUID();\n const appended = await storeFor(tx).append({ id, topic: message.topic, type: message.type, payload: message.payload, createdAt: now, availableAt: message.availableAt ?? now });\n return { id, appended };\n },\n async claim(query, options) {\n const s = await settings();\n return storeFor(query).claim({ ...options, limit: options.limit ?? s.batchSize, leaseSeconds: options.leaseSeconds ?? s.leaseSeconds, maxAttempts: s.maxAttempts, now: deps.clock.now() });\n },\n complete: (query, id, owner) => storeFor(query).complete(id, owner, deps.clock.now()),\n async fail(query, id, owner, error) {\n const s = await settings();\n return storeFor(query).fail(id, owner, describe(error), { now: deps.clock.now(), maxAttempts: s.maxAttempts, backoffSeconds: s.backoffSeconds, backoffMaxSeconds: s.backoffMaxSeconds });\n },\n retryDead: (query, id) => storeFor(query).retryDead(id, deps.clock.now()),\n async purge(query) {\n const s = await settings();\n return storeFor(query).purgeBefore(subHours(deps.clock.now(), s.retentionDays * 24));\n },\n stats: (query) => storeFor(query).stats(deps.clock.now()),\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\nimport { addSeconds } from \"@krizaka/time\";\n\nimport { FAIL_STATE, type FailOutcome, type OutboxMessage, type OutboxStats, type OutboxStore } from \"./port\";\n\ninterface Claimed {\n id: string;\n topic: string;\n event_type: string;\n payload: unknown;\n created_at: Date | string;\n attempts: number;\n}\n\n/**\n * The PostgreSQL store of the outbox, in `outbox.message` (see `outboxSchema`), over the `Query` it is given: the caller's\n * transaction for `append` (so the message commits or rolls back with the work), a pool for the worker's calls.\n *\n * `claim` is `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED)`: two workers never lease the same message while a lease is\n * valid. `complete` and `fail` only act for the lease's owner, so a worker that lost its lease cannot overwrite the new holder's outcome.\n * Times come from the caller (the injected clock), never from the database's `now()`.\n * @param schema the Postgres schema of the module, default `outbox`\n */\nexport function dbOutboxStore(query: Query, schema = \"outbox\"): OutboxStore {\n const message = `${ident(schema)}.message`;\n return {\n async append(m) {\n const rows = await query(\n `INSERT INTO ${message} (id, topic, event_type, payload, created_at, available_at) VALUES ($1, $2, $3, $4::jsonb, $5, $6) ON CONFLICT (id) DO NOTHING RETURNING 1`,\n [m.id, m.topic, m.type, JSON.stringify(m.payload), m.createdAt, m.availableAt],\n );\n return rows.length > 0;\n },\n async claim({ topic, owner, limit, leaseSeconds, maxAttempts, now }) {\n // a message whose last allowed attempt's lease expired with no answer is dead, not delivered a ninth time\n await query(\n `UPDATE ${message} SET dead_at = $1, leased_until = NULL, lease_owner = NULL, last_error = COALESCE(last_error, 'the last attempt''s lease expired with no answer')\n WHERE delivered_at IS NULL AND dead_at IS NULL AND attempts >= $2 AND leased_until IS NOT NULL AND leased_until <= $1`,\n [now, maxAttempts],\n );\n const rows = await query<Claimed>(\n `UPDATE ${message} AS m SET leased_until = $1, lease_owner = $2, attempts = m.attempts + 1\n WHERE m.id IN (\n SELECT id FROM ${message}\n WHERE delivered_at IS NULL AND dead_at IS NULL AND available_at <= $3 AND attempts < $4\n AND (leased_until IS NULL OR leased_until <= $3) AND ($5::text IS NULL OR topic = $5)\n ORDER BY available_at, created_at LIMIT $6\n FOR UPDATE SKIP LOCKED)\n RETURNING m.id, m.topic, m.event_type, m.payload, m.created_at, m.attempts`,\n [addSeconds(now, leaseSeconds), owner, now, maxAttempts, topic ?? null, limit],\n );\n return rows\n .map((r): OutboxMessage => ({ id: r.id, topic: r.topic, type: r.event_type, payload: r.payload, occurredAt: new Date(r.created_at), attempts: Number(r.attempts) }))\n .sort((a, b) => a.occurredAt.valueOf() - b.occurredAt.valueOf());\n },\n async complete(id, owner, now) {\n const rows = await query(\n `UPDATE ${message} SET delivered_at = $3, leased_until = NULL, lease_owner = NULL\n WHERE id = $1 AND lease_owner = $2 AND delivered_at IS NULL AND dead_at IS NULL RETURNING 1`,\n [id, owner, now],\n );\n return rows.length > 0;\n },\n async fail(id, owner, error, { now, maxAttempts, backoffSeconds, backoffMaxSeconds }): Promise<FailOutcome> {\n const [row] = await query<{ attempts: number; dead_at: Date | null; available_at: Date | string }>(\n `UPDATE ${message} SET last_error = $3::text, leased_until = NULL, lease_owner = NULL,\n dead_at = CASE WHEN attempts >= $5 THEN $4::timestamptz ELSE NULL END,\n available_at = CASE WHEN attempts >= $5 THEN available_at\n ELSE $4::timestamptz + make_interval(secs => LEAST($7::double precision, $6::double precision * power(2, attempts - 1))) END\n WHERE id = $1 AND lease_owner = $2 AND delivered_at IS NULL AND dead_at IS NULL\n RETURNING attempts, dead_at, available_at`,\n [id, owner, error, now, maxAttempts, backoffSeconds, backoffMaxSeconds],\n );\n if (!row) return { state: FAIL_STATE.lost };\n return row.dead_at ? { state: FAIL_STATE.dead } : { state: FAIL_STATE.retry, availableAt: new Date(row.available_at) };\n },\n async retryDead(id, now) {\n const rows = await query(`UPDATE ${message} SET dead_at = NULL, attempts = 0, available_at = $2 WHERE id = $1 AND dead_at IS NOT NULL RETURNING 1`, [id, now]);\n return rows.length > 0;\n },\n async purgeBefore(before) {\n const rows = await query(`DELETE FROM ${message} WHERE COALESCE(delivered_at, dead_at) < $1 RETURNING 1`, [before]);\n return rows.length;\n },\n async stats(now): Promise<OutboxStats> {\n const [row] = await query<{ pending: number; leased: number; delivered: number; dead: number }>(\n `SELECT count(*) FILTER (WHERE delivered_at IS NULL AND dead_at IS NULL AND (leased_until IS NULL OR leased_until <= $1))::int AS pending,\n count(*) FILTER (WHERE delivered_at IS NULL AND dead_at IS NULL AND leased_until > $1)::int AS leased,\n count(*) FILTER (WHERE delivered_at IS NOT NULL)::int AS delivered,\n count(*) FILTER (WHERE dead_at IS NOT NULL)::int AS dead\n FROM ${message}`,\n [now],\n );\n return { pending: row?.pending ?? 0, leased: row?.leased ?? 0, delivered: row?.delivered ?? 0, dead: row?.dead ?? 0 };\n },\n };\n}\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « outbox », in their own Postgres schema (ADR-001 §3). `message` is the outbox itself, the columns of the Java kit's\n * envelope (id, type, occurredAt = created_at, payload) and the state of its delivery; `processed` is the idempotent consumers'\n * record of the message ids they already applied. They reference no product table. Version 1 is frozen once released: a change is version 2.\n */\nexport const outboxSchema = defineModule({\n module: \"outbox\",\n migrations: [\n {\n version: 1,\n name: \"messages and processed\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.message (\n id uuid PRIMARY KEY,\n topic text NOT NULL CHECK (topic ~ '^[a-z][a-z0-9_.:-]{0,59}$'),\n event_type text NOT NULL CHECK (event_type ~ '^[a-z][a-z0-9_.-]{0,99}$'),\n payload jsonb NOT NULL,\n created_at timestamptz NOT NULL,\n available_at timestamptz NOT NULL,\n attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),\n leased_until timestamptz,\n lease_owner text,\n delivered_at timestamptz,\n dead_at timestamptz,\n last_error text,\n CHECK (delivered_at IS NULL OR dead_at IS NULL)\n);\nCREATE INDEX message_due ON ${c.schema}.message (topic, available_at) WHERE delivered_at IS NULL AND dead_at IS NULL;\nCREATE INDEX message_finished ON ${c.schema}.message (COALESCE(delivered_at, dead_at)) WHERE delivered_at IS NOT NULL OR dead_at IS NOT NULL;\nCREATE TABLE ${c.schema}.processed (\n consumer text NOT NULL CHECK (char_length(consumer) BETWEEN 1 AND 100),\n message_id uuid NOT NULL,\n processed_at timestamptz NOT NULL,\n PRIMARY KEY (consumer, message_id)\n);`,\n },\n ],\n});\n","import type { Outbox } from \"./outbox\";\nimport { FAIL_STATE, type OutboxMessage, type Query } from \"./port\";\n\nexport interface DrainSummary {\n claimed: number;\n delivered: number;\n retried: number;\n dead: number;\n /** Deliveries whose outcome could not be recorded because the lease had been lost to another worker. */\n lost: number;\n}\n\nexport interface DrainOptions {\n outbox: Outbox;\n /** A pool-level query: each call is its own statement, never one long transaction, so a lease is all that holds a message. */\n query: Query;\n /** Who this worker is (a hostname and a process id): only the holder of a lease can complete or fail its message. */\n owner: string;\n /** Delivers one message: resolve on success, throw on failure. It must be idempotent on `message.id` (at-least-once). */\n deliver: (message: OutboxMessage) => Promise<void>;\n /** Only this topic; default every topic. */\n topic?: string;\n}\n\n/**\n * One round: leases a batch, delivers each message in order, records each outcome. Safe to run from several instances at once.\n * A delivery that throws is retried later (or is dead after `maxAttempts`); the round goes on with the next message.\n * @returns what happened\n */\nexport async function drainOnce(options: DrainOptions): Promise<DrainSummary> {\n const { outbox, query, owner, deliver, topic } = options;\n const summary: DrainSummary = { claimed: 0, delivered: 0, retried: 0, dead: 0, lost: 0 };\n const batch = await outbox.claim(query, { owner, ...(topic === undefined ? {} : { topic }) });\n summary.claimed = batch.length;\n for (const message of batch) {\n let failure: { error: unknown } | null = null;\n try {\n await deliver(message);\n } catch (error) {\n failure = { error };\n }\n if (failure === null) {\n if (await outbox.complete(query, message.id, owner)) summary.delivered += 1;\n else summary.lost += 1;\n continue;\n }\n const outcome = await outbox.fail(query, message.id, owner, failure.error);\n if (outcome.state === FAIL_STATE.retry) summary.retried += 1;\n else if (outcome.state === FAIL_STATE.dead) summary.dead += 1;\n else summary.lost += 1;\n }\n return summary;\n}\n\nexport interface WorkerOptions extends DrainOptions {\n /** Waits this many seconds (a timer: the package has none of its own). */\n sleep: (seconds: number) => Promise<void>;\n /** The worker stops when this signal is aborted (checked between rounds). */\n signal?: AbortSignal;\n /** Seconds to wait when a round found nothing. Default 5. */\n idleSeconds?: number;\n /** Told of a round that threw (the store was unreachable): the loop waits and goes on. */\n onError?: (error: unknown) => void;\n}\n\n// @fixed: how long an idle worker waits before it looks again, a technical polling interval\nconst IDLE_SECONDS = 5;\n\n/**\n * The loop a product starts (Next.js `instrumentation.ts`, a worker process): drain, and sleep when there was nothing. It has no\n * timer of its own — the product passes `sleep` — and it ends when the signal is aborted.\n */\nexport async function runWorker(options: WorkerOptions): Promise<void> {\n const idle = options.idleSeconds ?? IDLE_SECONDS;\n while (!options.signal?.aborted) {\n let waited: boolean;\n try {\n waited = (await drainOnce(options)).claimed === 0;\n } catch (error) {\n options.onError?.(error);\n waited = true;\n }\n if (waited && !options.signal?.aborted) await options.sleep(idle);\n }\n}\n"],"mappings":"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","names":["ident","ident","addSeconds","ident","addSeconds","backoffSeconds","ident"]}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ports of « outbox »: a message written in the caller's transaction and delivered after the commit, AT LEAST ONCE. Generic:
|
|
3
|
+
* no broker, no product import, no database client (a `Query` comes in), no wall clock (a `Clock` comes in).
|
|
4
|
+
*
|
|
5
|
+
* THE GUARANTEE. A claim is exclusive while its lease is valid. A worker that delivers and then crashes before `complete` lets the
|
|
6
|
+
* lease expire, and the message is delivered AGAIN. So every consumer must be idempotent on the message id (see `consumeOnce`).
|
|
7
|
+
* The outbox never promises « exactly once ».
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What a product appends. */
|
|
11
|
+
interface NewMessage {
|
|
12
|
+
/** Where it goes: a worker claims one topic (`mail`, `recommender`, `webhooks`). Lower-case letters, digits, `_ . : -`. */
|
|
13
|
+
topic: string;
|
|
14
|
+
/** What happened, as a routing key (`evt.credit.granted.v1`): the Java kit's form. Lower-case letters, digits, `_ . -`. */
|
|
15
|
+
type: string;
|
|
16
|
+
/** The event's data: JSON. Never a secret, and as little personal data as the consumer needs. */
|
|
17
|
+
payload: unknown;
|
|
18
|
+
/** A stable id (a UUID) when the same fact can be appended twice: the second append is a no-op. Default: a fresh UUID. */
|
|
19
|
+
id?: string;
|
|
20
|
+
/** Not before this instant (a scheduled delivery). Default: now. */
|
|
21
|
+
availableAt?: Date;
|
|
22
|
+
}
|
|
23
|
+
/** The envelope a worker receives: the Java kit's `id`, `type`, `occurredAt`, `payload`, and where it is in its attempts. */
|
|
24
|
+
interface OutboxMessage {
|
|
25
|
+
id: string;
|
|
26
|
+
topic: string;
|
|
27
|
+
type: string;
|
|
28
|
+
payload: unknown;
|
|
29
|
+
occurredAt: Date;
|
|
30
|
+
/** How many deliveries have been started for it, this one included. */
|
|
31
|
+
attempts: number;
|
|
32
|
+
}
|
|
33
|
+
/** The knobs of the outbox, read from the admin's settings (`@krizaka/outbox/settings`). */
|
|
34
|
+
interface OutboxSettings {
|
|
35
|
+
/** Messages a worker claims at once. */
|
|
36
|
+
batchSize: number;
|
|
37
|
+
/** Seconds a claim holds a message before another worker may take it. Longer than the slowest delivery. */
|
|
38
|
+
leaseSeconds: number;
|
|
39
|
+
/** Deliveries started before a message is dead. */
|
|
40
|
+
maxAttempts: number;
|
|
41
|
+
/** Seconds before the second attempt; it doubles with each attempt. */
|
|
42
|
+
backoffSeconds: number;
|
|
43
|
+
/** The most seconds a retry waits. */
|
|
44
|
+
backoffMaxSeconds: number;
|
|
45
|
+
/** Days a delivered or dead message is kept before `purge`. */
|
|
46
|
+
retentionDays: number;
|
|
47
|
+
}
|
|
48
|
+
/** What `claim` takes. */
|
|
49
|
+
interface ClaimOptions {
|
|
50
|
+
/** Only this topic; default every topic. */
|
|
51
|
+
topic?: string;
|
|
52
|
+
/** Who claims: a worker's id, so that only the holder of a lease can complete or fail the message. */
|
|
53
|
+
owner: string;
|
|
54
|
+
}
|
|
55
|
+
/** What became of a failed delivery. */
|
|
56
|
+
declare const FAIL_STATE: {
|
|
57
|
+
readonly retry: "retry";
|
|
58
|
+
readonly dead: "dead";
|
|
59
|
+
readonly lost: "lost";
|
|
60
|
+
};
|
|
61
|
+
/** The result of `fail`: the message will be retried, is dead, or was not this worker's any more. */
|
|
62
|
+
type FailOutcome = {
|
|
63
|
+
state: typeof FAIL_STATE.retry;
|
|
64
|
+
availableAt: Date;
|
|
65
|
+
} | {
|
|
66
|
+
state: typeof FAIL_STATE.dead;
|
|
67
|
+
} | {
|
|
68
|
+
state: typeof FAIL_STATE.lost;
|
|
69
|
+
};
|
|
70
|
+
interface OutboxStats {
|
|
71
|
+
/** Waiting for a worker (including retries). */
|
|
72
|
+
pending: number;
|
|
73
|
+
/** Held by a worker right now. */
|
|
74
|
+
leased: number;
|
|
75
|
+
delivered: number;
|
|
76
|
+
dead: number;
|
|
77
|
+
}
|
|
78
|
+
/** Where the messages live. Every adapter runs `outboxStoreContract` (`@krizaka/outbox/testkit`). Times come from the caller. */
|
|
79
|
+
interface OutboxStore {
|
|
80
|
+
/** Writes the message. A second append of the same id writes nothing and answers `false`. */
|
|
81
|
+
append(message: {
|
|
82
|
+
id: string;
|
|
83
|
+
topic: string;
|
|
84
|
+
type: string;
|
|
85
|
+
payload: unknown;
|
|
86
|
+
createdAt: Date;
|
|
87
|
+
availableAt: Date;
|
|
88
|
+
}): Promise<boolean>;
|
|
89
|
+
/** First marks dead the messages whose last attempt's lease expired with no answer, then leases up to `limit` due messages. */
|
|
90
|
+
claim(options: ClaimOptions & {
|
|
91
|
+
limit: number;
|
|
92
|
+
leaseSeconds: number;
|
|
93
|
+
maxAttempts: number;
|
|
94
|
+
now: Date;
|
|
95
|
+
}): Promise<OutboxMessage[]>;
|
|
96
|
+
/** Marks it delivered, when `owner` still holds it. `false` when the lease was lost: the other worker delivers. */
|
|
97
|
+
complete(id: string, owner: string, now: Date): Promise<boolean>;
|
|
98
|
+
/** Records the failure: back off, or dead after `maxAttempts`. */
|
|
99
|
+
fail(id: string, owner: string, error: string, options: {
|
|
100
|
+
now: Date;
|
|
101
|
+
maxAttempts: number;
|
|
102
|
+
backoffSeconds: number;
|
|
103
|
+
backoffMaxSeconds: number;
|
|
104
|
+
}): Promise<FailOutcome>;
|
|
105
|
+
/** Gives a dead message another life: no attempts, due now. `false` when it is not dead. */
|
|
106
|
+
retryDead(id: string, now: Date): Promise<boolean>;
|
|
107
|
+
/** Removes the delivered and dead messages older than `before`. Returns how many. */
|
|
108
|
+
purgeBefore(before: Date): Promise<number>;
|
|
109
|
+
stats(now: Date): Promise<OutboxStats>;
|
|
110
|
+
}
|
|
111
|
+
declare const OUTBOX_CODE: {
|
|
112
|
+
readonly badTopic: "OUTBOX_BAD_TOPIC";
|
|
113
|
+
readonly badType: "OUTBOX_BAD_TYPE";
|
|
114
|
+
readonly badId: "OUTBOX_BAD_ID";
|
|
115
|
+
readonly badPayload: "OUTBOX_BAD_PAYLOAD";
|
|
116
|
+
readonly badSettings: "OUTBOX_BAD_SETTINGS";
|
|
117
|
+
};
|
|
118
|
+
type OutboxCode = (typeof OUTBOX_CODE)[keyof typeof OUTBOX_CODE];
|
|
119
|
+
declare class OutboxError extends Error {
|
|
120
|
+
readonly code: OutboxCode;
|
|
121
|
+
constructor(code: OutboxCode, message: string);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { type ClaimOptions as C, type FailOutcome as F, type NewMessage as N, type OutboxStore as O, type OutboxMessage as a, type OutboxStats as b, type OutboxSettings as c, FAIL_STATE as d, OUTBOX_CODE as e, type OutboxCode as f, OutboxError as g };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as _krizaka_settings from '@krizaka/settings';
|
|
2
|
+
import { c as OutboxSettings } from './port-CXch5iMa.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The settings of « outbox », a fragment the product adds to its registry (`@krizaka/outbox/settings`): the admin tunes the worker
|
|
6
|
+
* without a redeploy. Global scope only.
|
|
7
|
+
*/
|
|
8
|
+
declare const outboxSettings: {
|
|
9
|
+
readonly "outbox.batchSize": _krizaka_settings.Def<number>;
|
|
10
|
+
readonly "outbox.leaseSeconds": _krizaka_settings.Def<number>;
|
|
11
|
+
readonly "outbox.maxAttempts": _krizaka_settings.Def<number>;
|
|
12
|
+
readonly "outbox.backoffSeconds": _krizaka_settings.Def<number>;
|
|
13
|
+
readonly "outbox.backoffMaxSeconds": _krizaka_settings.Def<number>;
|
|
14
|
+
readonly "outbox.retentionDays": _krizaka_settings.Def<number>;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The outbox's settings as `createOutbox` takes them, from the product's accessor (`(key) => service.get(key, ctx)`).
|
|
18
|
+
* A key the accessor does not know falls back to the default.
|
|
19
|
+
*/
|
|
20
|
+
declare function readOutboxSettings(read: (key: keyof typeof outboxSettings) => number | undefined | Promise<number | undefined>): Promise<OutboxSettings>;
|
|
21
|
+
|
|
22
|
+
export { outboxSettings, readOutboxSettings };
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OUTBOX_BOUNDS,
|
|
3
|
+
OUTBOX_DEFAULTS
|
|
4
|
+
} from "./chunk-AD76AB44.js";
|
|
5
|
+
import "./chunk-7WIJNAGK.js";
|
|
6
|
+
|
|
7
|
+
// src/settings.ts
|
|
8
|
+
import { integer } from "@krizaka/settings";
|
|
9
|
+
var def = (key, unit) => integer("outbox", OUTBOX_DEFAULTS[key], { min: OUTBOX_BOUNDS[key][0], max: OUTBOX_BOUNDS[key][1], unit });
|
|
10
|
+
var outboxSettings = {
|
|
11
|
+
"outbox.batchSize": def("batchSize", "messages"),
|
|
12
|
+
"outbox.leaseSeconds": def("leaseSeconds", "seconds"),
|
|
13
|
+
"outbox.maxAttempts": def("maxAttempts", "attempts"),
|
|
14
|
+
"outbox.backoffSeconds": def("backoffSeconds", "seconds"),
|
|
15
|
+
"outbox.backoffMaxSeconds": def("backoffMaxSeconds", "seconds"),
|
|
16
|
+
"outbox.retentionDays": def("retentionDays", "days")
|
|
17
|
+
};
|
|
18
|
+
async function readOutboxSettings(read) {
|
|
19
|
+
const value = async (key) => await read(`outbox.${key}`) ?? OUTBOX_DEFAULTS[key];
|
|
20
|
+
return {
|
|
21
|
+
batchSize: await value("batchSize"),
|
|
22
|
+
leaseSeconds: await value("leaseSeconds"),
|
|
23
|
+
maxAttempts: await value("maxAttempts"),
|
|
24
|
+
backoffSeconds: await value("backoffSeconds"),
|
|
25
|
+
backoffMaxSeconds: await value("backoffMaxSeconds"),
|
|
26
|
+
retentionDays: await value("retentionDays")
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
outboxSettings,
|
|
31
|
+
readOutboxSettings
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=settings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { integer, type Registry } from \"@krizaka/settings\";\n\nimport type { OutboxSettings } from \"./port\";\nimport { OUTBOX_BOUNDS, OUTBOX_DEFAULTS } from \"./rules\";\n\nconst def = <K extends keyof OutboxSettings>(key: K, unit: string) => integer(\"outbox\", OUTBOX_DEFAULTS[key], { min: OUTBOX_BOUNDS[key][0], max: OUTBOX_BOUNDS[key][1], unit });\n\n/**\n * The settings of « outbox », a fragment the product adds to its registry (`@krizaka/outbox/settings`): the admin tunes the worker\n * without a redeploy. Global scope only.\n */\nexport const outboxSettings = {\n \"outbox.batchSize\": def(\"batchSize\", \"messages\"),\n \"outbox.leaseSeconds\": def(\"leaseSeconds\", \"seconds\"),\n \"outbox.maxAttempts\": def(\"maxAttempts\", \"attempts\"),\n \"outbox.backoffSeconds\": def(\"backoffSeconds\", \"seconds\"),\n \"outbox.backoffMaxSeconds\": def(\"backoffMaxSeconds\", \"seconds\"),\n \"outbox.retentionDays\": def(\"retentionDays\", \"days\"),\n} as const satisfies Registry;\n\n/**\n * The outbox's settings as `createOutbox` takes them, from the product's accessor (`(key) => service.get(key, ctx)`).\n * A key the accessor does not know falls back to the default.\n */\nexport async function readOutboxSettings(read: (key: keyof typeof outboxSettings) => number | undefined | Promise<number | undefined>): Promise<OutboxSettings> {\n const value = async (key: keyof OutboxSettings) => (await read(`outbox.${key}`)) ?? OUTBOX_DEFAULTS[key];\n return {\n batchSize: await value(\"batchSize\"),\n leaseSeconds: await value(\"leaseSeconds\"),\n maxAttempts: await value(\"maxAttempts\"),\n backoffSeconds: await value(\"backoffSeconds\"),\n backoffMaxSeconds: await value(\"backoffMaxSeconds\"),\n retentionDays: await value(\"retentionDays\"),\n };\n}\n"],"mappings":";;;;;;;AAAA,SAAS,eAA8B;AAKvC,IAAM,MAAM,CAAiC,KAAQ,SAAiB,QAAQ,UAAU,gBAAgB,GAAG,GAAG,EAAE,KAAK,cAAc,GAAG,EAAE,CAAC,GAAG,KAAK,cAAc,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC;AAMvK,IAAM,iBAAiB;AAAA,EAC5B,oBAAoB,IAAI,aAAa,UAAU;AAAA,EAC/C,uBAAuB,IAAI,gBAAgB,SAAS;AAAA,EACpD,sBAAsB,IAAI,eAAe,UAAU;AAAA,EACnD,yBAAyB,IAAI,kBAAkB,SAAS;AAAA,EACxD,4BAA4B,IAAI,qBAAqB,SAAS;AAAA,EAC9D,wBAAwB,IAAI,iBAAiB,MAAM;AACrD;AAMA,eAAsB,mBAAmB,MAAuH;AAC9J,QAAM,QAAQ,OAAO,QAA+B,MAAM,KAAK,UAAU,GAAG,EAAE,KAAM,gBAAgB,GAAG;AACvG,SAAO;AAAA,IACL,WAAW,MAAM,MAAM,WAAW;AAAA,IAClC,cAAc,MAAM,MAAM,cAAc;AAAA,IACxC,aAAa,MAAM,MAAM,aAAa;AAAA,IACtC,gBAAgB,MAAM,MAAM,gBAAgB;AAAA,IAC5C,mBAAmB,MAAM,MAAM,mBAAmB;AAAA,IAClD,eAAe,MAAM,MAAM,eAAe;AAAA,EAC5C;AACF;","names":[]}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { O as OutboxStore } from './port-CXch5iMa.js';
|
|
2
|
+
|
|
3
|
+
/** What every `OutboxStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store. */
|
|
4
|
+
declare function outboxStoreContract(name: string, make: () => OutboxStore | Promise<OutboxStore>): void;
|
|
5
|
+
|
|
6
|
+
export { outboxStoreContract };
|