@krizaka/outbox 0.1.2 → 0.1.4
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 +1 -1
- package/dist/{chunk-7WIJNAGK.js → chunk-A6RZGG5L.js} +1 -1
- package/dist/{chunk-7WIJNAGK.js.map → chunk-A6RZGG5L.js.map} +1 -1
- package/dist/chunk-Q5RPIANI.js +65 -0
- package/dist/chunk-Q5RPIANI.js.map +1 -0
- package/dist/index.js +75 -13
- package/dist/index.js.map +1 -1
- package/dist/settings.js +2 -2
- package/dist/settings.js.map +1 -1
- package/dist/testkit.js +45 -7
- package/dist/testkit.js.map +1 -1
- package/package.json +5 -5
- package/dist/chunk-AD76AB44.js +0 -44
- package/dist/chunk-AD76AB44.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @krizaka/outbox
|
|
2
2
|
|
|
3
|
-
The transactional outbox of every Krizaka product. An event is **written in the caller's transaction**, so it exists exactly when the work committed, and is **delivered after the commit, at least once**, by workers that lease a batch. No broker: PostgreSQL and `FOR UPDATE SKIP LOCKED` share the work between instances. It replaces Akinaza's `mail_outbox` (whose `flushOutbox` nobody called)
|
|
3
|
+
The transactional outbox of every Krizaka product. An event is **written in the caller's transaction**, so it exists exactly when the work committed, and is **delivered after the commit, at least once**, by workers that lease a batch. No broker: PostgreSQL and `FOR UPDATE SKIP LOCKED` share the work between instances. It replaces Akinaza's `mail_outbox` (whose `flushOutbox` nobody called) and the analytics outboxes the products carried; it is the TypeScript twin of `krizaka-messaging`'s `krizaka_outbox` (the envelope is `id`, `type`, `occurredAt`, `payload`).
|
|
4
4
|
|
|
5
5
|
> **The guarantee is *at least once*. Consumers must be idempotent on the message id.** A claim is exclusive while its lease is valid. A worker that delivers and then crashes before `complete` lets the lease expire, and the message is delivered **again**. The outbox never promises « exactly once ».
|
|
6
6
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/port.ts"],"sourcesContent":["/**\n * The ports of « outbox »: a message written in the caller's transaction and delivered after the commit, AT LEAST ONCE. Generic:\n * no broker, no product import, no database client (a `Query` comes in), no wall clock (a `Clock` comes in).\n *\n * THE GUARANTEE. A claim is exclusive while its lease is valid. A worker that delivers and then crashes before `complete` lets the\n * lease expire, and the message is delivered AGAIN. So every consumer must be idempotent on the message id (see `consumeOnce`).\n * The outbox never promises « exactly once ».\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/** What a product appends. */\nexport interface NewMessage {\n /** Where it goes: a worker claims one topic (`mail`, `recommender`, `webhooks`). Lower-case letters, digits, `_ . : -`. */\n topic: string;\n /** What happened, as a routing key (`evt.credit.granted.v1`): the Java kit's form. Lower-case letters, digits, `_ . -`. */\n type: string;\n /** The event's data: JSON. Never a secret, and as little personal data as the consumer needs. */\n payload: unknown;\n /** A stable id (a UUID) when the same fact can be appended twice: the second append is a no-op. Default: a fresh UUID. */\n id?: string;\n /** Not before this instant (a scheduled delivery). Default: now. */\n availableAt?: Date;\n}\n\n/** The envelope a worker receives: the Java kit's `id`, `type`, `occurredAt`, `payload`, and where it is in its attempts. */\nexport interface OutboxMessage {\n id: string;\n topic: string;\n type: string;\n payload: unknown;\n occurredAt: Date;\n /** How many deliveries have been started for it, this one included. */\n attempts: number;\n}\n\n/** The knobs of the outbox, read from the admin's settings (`@krizaka/outbox/settings`). */\nexport interface OutboxSettings {\n /** Messages a worker claims at once. */\n batchSize: number;\n /** Seconds a claim holds a message before another worker may take it. Longer than the slowest delivery. */\n leaseSeconds: number;\n /** Deliveries started before a message is dead. */\n maxAttempts: number;\n /** Seconds before the second attempt; it doubles with each attempt. */\n backoffSeconds: number;\n /** The most seconds a retry waits. */\n backoffMaxSeconds: number;\n /** Days a delivered or dead message is kept before `purge`. */\n retentionDays: number;\n}\n\n/** What `claim` takes. */\nexport interface ClaimOptions {\n /** Only this topic; default every topic. */\n topic?: string;\n /** Who claims: a worker's id, so that only the holder of a lease can complete or fail the message. */\n owner: string;\n}\n\n/** What became of a failed delivery. */\nexport const FAIL_STATE = { retry: \"retry\", dead: \"dead\", lost: \"lost\" } as const;\n\n/** The result of `fail`: the message will be retried, is dead, or was not this worker's any more. */\nexport type FailOutcome
|
|
1
|
+
{"version":3,"sources":["../src/port.ts"],"sourcesContent":["/**\n * The ports of « outbox »: a message written in the caller's transaction and delivered after the commit, AT LEAST ONCE. Generic:\n * no broker, no product import, no database client (a `Query` comes in), no wall clock (a `Clock` comes in).\n *\n * THE GUARANTEE. A claim is exclusive while its lease is valid. A worker that delivers and then crashes before `complete` lets the\n * lease expire, and the message is delivered AGAIN. So every consumer must be idempotent on the message id (see `consumeOnce`).\n * The outbox never promises « exactly once ».\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/** What a product appends. */\nexport interface NewMessage {\n /** Where it goes: a worker claims one topic (`mail`, `recommender`, `webhooks`). Lower-case letters, digits, `_ . : -`. */\n topic: string;\n /** What happened, as a routing key (`evt.credit.granted.v1`): the Java kit's form. Lower-case letters, digits, `_ . -`. */\n type: string;\n /** The event's data: JSON. Never a secret, and as little personal data as the consumer needs. */\n payload: unknown;\n /** A stable id (a UUID) when the same fact can be appended twice: the second append is a no-op. Default: a fresh UUID. */\n id?: string;\n /** Not before this instant (a scheduled delivery). Default: now. */\n availableAt?: Date;\n}\n\n/** The envelope a worker receives: the Java kit's `id`, `type`, `occurredAt`, `payload`, and where it is in its attempts. */\nexport interface OutboxMessage {\n id: string;\n topic: string;\n type: string;\n payload: unknown;\n occurredAt: Date;\n /** How many deliveries have been started for it, this one included. */\n attempts: number;\n}\n\n/** The knobs of the outbox, read from the admin's settings (`@krizaka/outbox/settings`). */\nexport interface OutboxSettings {\n /** Messages a worker claims at once. */\n batchSize: number;\n /** Seconds a claim holds a message before another worker may take it. Longer than the slowest delivery. */\n leaseSeconds: number;\n /** Deliveries started before a message is dead. */\n maxAttempts: number;\n /** Seconds before the second attempt; it doubles with each attempt. */\n backoffSeconds: number;\n /** The most seconds a retry waits. */\n backoffMaxSeconds: number;\n /** Days a delivered or dead message is kept before `purge`. */\n retentionDays: number;\n}\n\n/** What `claim` takes. */\nexport interface ClaimOptions {\n /** Only this topic; default every topic. */\n topic?: string;\n /** Who claims: a worker's id, so that only the holder of a lease can complete or fail the message. */\n owner: string;\n}\n\n/** What became of a failed delivery. */\nexport const FAIL_STATE = { retry: \"retry\", dead: \"dead\", lost: \"lost\" } as const;\n\n/** The result of `fail`: the message will be retried, is dead, or was not this worker's any more. */\nexport type FailOutcome =\n | { state: typeof FAIL_STATE.retry; availableAt: Date }\n | { state: typeof FAIL_STATE.dead }\n | { state: typeof FAIL_STATE.lost };\n\nexport interface OutboxStats {\n /** Waiting for a worker (including retries). */\n pending: number;\n /** Held by a worker right now. */\n leased: number;\n delivered: number;\n dead: number;\n}\n\n/** Where the messages live. Every adapter runs `outboxStoreContract` (`@krizaka/outbox/testkit`). Times come from the caller. */\nexport interface OutboxStore {\n /** Writes the message. A second append of the same id writes nothing and answers `false`. */\n append(message: {\n id: string;\n topic: string;\n type: string;\n payload: unknown;\n createdAt: Date;\n availableAt: Date;\n }): Promise<boolean>;\n /** First marks dead the messages whose last attempt's lease expired with no answer, then leases up to `limit` due messages. */\n claim(\n options: ClaimOptions & { limit: number; leaseSeconds: number; maxAttempts: number; now: Date },\n ): Promise<OutboxMessage[]>;\n /** Marks it delivered, when `owner` still holds it. `false` when the lease was lost: the other worker delivers. */\n complete(id: string, owner: string, now: Date): Promise<boolean>;\n /** Records the failure: back off, or dead after `maxAttempts`. */\n fail(\n id: string,\n owner: string,\n error: string,\n options: { now: Date; maxAttempts: number; backoffSeconds: number; backoffMaxSeconds: number },\n ): Promise<FailOutcome>;\n /** Gives a dead message another life: no attempts, due now. `false` when it is not dead. */\n retryDead(id: string, now: Date): Promise<boolean>;\n /** Removes the delivered and dead messages older than `before`. Returns how many. */\n purgeBefore(before: Date): Promise<number>;\n stats(now: Date): Promise<OutboxStats>;\n}\n\nexport const OUTBOX_CODE = {\n badTopic: \"OUTBOX_BAD_TOPIC\",\n badType: \"OUTBOX_BAD_TYPE\",\n badId: \"OUTBOX_BAD_ID\",\n badPayload: \"OUTBOX_BAD_PAYLOAD\",\n badSettings: \"OUTBOX_BAD_SETTINGS\",\n} as const;\nexport type OutboxCode = (typeof OUTBOX_CODE)[keyof typeof OUTBOX_CODE];\n\nexport class OutboxError extends Error {\n constructor(\n readonly code: OutboxCode,\n message: string,\n ) {\n super(message);\n this.name = \"OutboxError\";\n }\n}\n"],"mappings":";AA4DO,IAAM,aAAa,EAAE,OAAO,SAAS,MAAM,QAAQ,MAAM,OAAO;AAgDhE,IAAM,cAAc;AAAA,EACzB,UAAU;AAAA,EACV,SAAS;AAAA,EACT,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,aAAa;AACf;AAGO,IAAM,cAAN,cAA0B,MAAM;AAAA,EACrC,YACW,MACT,SACA;AACA,UAAM,OAAO;AAHJ;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EALW;AAMb;","names":[]}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OUTBOX_CODE,
|
|
3
|
+
OutboxError
|
|
4
|
+
} from "./chunk-A6RZGG5L.js";
|
|
5
|
+
|
|
6
|
+
// src/rules.ts
|
|
7
|
+
var OUTBOX_DEFAULTS = {
|
|
8
|
+
batchSize: 50,
|
|
9
|
+
leaseSeconds: 60,
|
|
10
|
+
maxAttempts: 8,
|
|
11
|
+
backoffSeconds: 30,
|
|
12
|
+
backoffMaxSeconds: 3600,
|
|
13
|
+
retentionDays: 7
|
|
14
|
+
};
|
|
15
|
+
var OUTBOX_BOUNDS = {
|
|
16
|
+
batchSize: [1, 500],
|
|
17
|
+
leaseSeconds: [10, 3600],
|
|
18
|
+
maxAttempts: [1, 50],
|
|
19
|
+
backoffSeconds: [1, 3600],
|
|
20
|
+
backoffMaxSeconds: [1, 86400],
|
|
21
|
+
retentionDays: [1, 365]
|
|
22
|
+
};
|
|
23
|
+
var TOPIC = /^[a-z][a-z0-9_.:-]{0,59}$/;
|
|
24
|
+
var TYPE = /^[a-z][a-z0-9_.-]{0,99}$/;
|
|
25
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
26
|
+
function checkMessage(input) {
|
|
27
|
+
if (!TOPIC.test(input.topic))
|
|
28
|
+
throw new OutboxError(
|
|
29
|
+
OUTBOX_CODE.badTopic,
|
|
30
|
+
`topic \xAB ${input.topic} \xBB: lower-case letters, digits and _ . : -, starting with a letter, at most 60 characters`
|
|
31
|
+
);
|
|
32
|
+
if (!TYPE.test(input.type))
|
|
33
|
+
throw new OutboxError(
|
|
34
|
+
OUTBOX_CODE.badType,
|
|
35
|
+
`type \xAB ${input.type} \xBB: lower-case letters, digits and _ . -, starting with a letter, at most 100 characters (evt.credit.granted.v1)`
|
|
36
|
+
);
|
|
37
|
+
if (input.id !== void 0 && !UUID.test(input.id))
|
|
38
|
+
throw new OutboxError(OUTBOX_CODE.badId, `id \xAB ${input.id} \xBB is not a UUID`);
|
|
39
|
+
if (input.payload === void 0 || JSON.stringify(input.payload) === void 0)
|
|
40
|
+
throw new OutboxError(OUTBOX_CODE.badPayload, "the payload must be JSON-serialisable (and not undefined)");
|
|
41
|
+
}
|
|
42
|
+
function checkSettings(settings) {
|
|
43
|
+
for (const key of Object.keys(OUTBOX_BOUNDS)) {
|
|
44
|
+
const [min, max] = OUTBOX_BOUNDS[key];
|
|
45
|
+
const value = settings[key];
|
|
46
|
+
if (!Number.isInteger(value) || value < min || value > max)
|
|
47
|
+
throw new OutboxError(
|
|
48
|
+
OUTBOX_CODE.badSettings,
|
|
49
|
+
`outbox.${key} must be a whole number from ${min} to ${max}, got ${value}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return settings;
|
|
53
|
+
}
|
|
54
|
+
function backoffSeconds(attempts, settings) {
|
|
55
|
+
return Math.min(settings.backoffMaxSeconds, settings.backoffSeconds * 2 ** Math.max(0, attempts - 1));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
OUTBOX_DEFAULTS,
|
|
60
|
+
OUTBOX_BOUNDS,
|
|
61
|
+
checkMessage,
|
|
62
|
+
checkSettings,
|
|
63
|
+
backoffSeconds
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=chunk-Q5RPIANI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/rules.ts"],"sourcesContent":["import { OUTBOX_CODE, OutboxError, type OutboxSettings } from \"./port\";\n\n/**\n * The defaults of the settings, and their bounds. The settings fragment (`@krizaka/outbox/settings`) is built from these, so the\n * admin sees the same numbers the code falls back to.\n */\n// @fixed: the defaults and bounds of the admin's knobs; the admin chooses within them\nexport const OUTBOX_DEFAULTS: OutboxSettings = {\n batchSize: 50,\n leaseSeconds: 60,\n maxAttempts: 8,\n backoffSeconds: 30,\n backoffMaxSeconds: 3600,\n retentionDays: 7,\n};\n\n/** The bounds of each setting: [min, max]. */\n// @fixed: what an admin may set\nexport const OUTBOX_BOUNDS: Readonly<Record<keyof OutboxSettings, readonly [number, number]>> = {\n batchSize: [1, 500],\n leaseSeconds: [10, 3600],\n maxAttempts: [1, 50],\n backoffSeconds: [1, 3600],\n backoffMaxSeconds: [1, 86_400],\n retentionDays: [1, 365],\n};\n\nconst TOPIC = /^[a-z][a-z0-9_.:-]{0,59}$/;\nconst TYPE = /^[a-z][a-z0-9_.-]{0,99}$/;\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** @throws OutboxError for a topic, a type or an id that is not well formed, or a payload that is not JSON */\nexport function checkMessage(input: { topic: string; type: string; id?: string; payload: unknown }): void {\n if (!TOPIC.test(input.topic))\n throw new OutboxError(\n OUTBOX_CODE.badTopic,\n `topic « ${input.topic} »: lower-case letters, digits and _ . : -, starting with a letter, at most 60 characters`,\n );\n if (!TYPE.test(input.type))\n throw new OutboxError(\n OUTBOX_CODE.badType,\n `type « ${input.type} »: lower-case letters, digits and _ . -, starting with a letter, at most 100 characters (evt.credit.granted.v1)`,\n );\n if (input.id !== undefined && !UUID.test(input.id))\n throw new OutboxError(OUTBOX_CODE.badId, `id « ${input.id} » is not a UUID`);\n if (input.payload === undefined || JSON.stringify(input.payload) === undefined)\n throw new OutboxError(OUTBOX_CODE.badPayload, \"the payload must be JSON-serialisable (and not undefined)\");\n}\n\n/** @throws OutboxError when a setting is not a whole number within its bounds */\nexport function checkSettings(settings: OutboxSettings): OutboxSettings {\n for (const key of Object.keys(OUTBOX_BOUNDS) as (keyof OutboxSettings)[]) {\n const [min, max] = OUTBOX_BOUNDS[key];\n const value = settings[key];\n if (!Number.isInteger(value) || value < min || value > max)\n throw new OutboxError(\n OUTBOX_CODE.badSettings,\n `outbox.${key} must be a whole number from ${min} to ${max}, got ${value}`,\n );\n }\n return settings;\n}\n\n/**\n * The seconds a message waits after its `attempts`-th delivery failed: the base for the first, doubling with each, capped.\n * @param attempts deliveries started so far (1 for the first failure)\n */\nexport function backoffSeconds(\n attempts: number,\n settings: Pick<OutboxSettings, \"backoffSeconds\" | \"backoffMaxSeconds\">,\n): number {\n return Math.min(settings.backoffMaxSeconds, settings.backoffSeconds * 2 ** Math.max(0, attempts - 1));\n}\n"],"mappings":";;;;;;AAOO,IAAM,kBAAkC;AAAA,EAC7C,WAAW;AAAA,EACX,cAAc;AAAA,EACd,aAAa;AAAA,EACb,gBAAgB;AAAA,EAChB,mBAAmB;AAAA,EACnB,eAAe;AACjB;AAIO,IAAM,gBAAmF;AAAA,EAC9F,WAAW,CAAC,GAAG,GAAG;AAAA,EAClB,cAAc,CAAC,IAAI,IAAI;AAAA,EACvB,aAAa,CAAC,GAAG,EAAE;AAAA,EACnB,gBAAgB,CAAC,GAAG,IAAI;AAAA,EACxB,mBAAmB,CAAC,GAAG,KAAM;AAAA,EAC7B,eAAe,CAAC,GAAG,GAAG;AACxB;AAEA,IAAM,QAAQ;AACd,IAAM,OAAO;AACb,IAAM,OAAO;AAGN,SAAS,aAAa,OAA6E;AACxG,MAAI,CAAC,MAAM,KAAK,MAAM,KAAK;AACzB,UAAM,IAAI;AAAA,MACR,YAAY;AAAA,MACZ,cAAW,MAAM,KAAK;AAAA,IACxB;AACF,MAAI,CAAC,KAAK,KAAK,MAAM,IAAI;AACvB,UAAM,IAAI;AAAA,MACR,YAAY;AAAA,MACZ,aAAU,MAAM,IAAI;AAAA,IACtB;AACF,MAAI,MAAM,OAAO,UAAa,CAAC,KAAK,KAAK,MAAM,EAAE;AAC/C,UAAM,IAAI,YAAY,YAAY,OAAO,WAAQ,MAAM,EAAE,qBAAkB;AAC7E,MAAI,MAAM,YAAY,UAAa,KAAK,UAAU,MAAM,OAAO,MAAM;AACnE,UAAM,IAAI,YAAY,YAAY,YAAY,2DAA2D;AAC7G;AAGO,SAAS,cAAc,UAA0C;AACtE,aAAW,OAAO,OAAO,KAAK,aAAa,GAA+B;AACxE,UAAM,CAAC,KAAK,GAAG,IAAI,cAAc,GAAG;AACpC,UAAM,QAAQ,SAAS,GAAG;AAC1B,QAAI,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,OAAO,QAAQ;AACrD,YAAM,IAAI;AAAA,QACR,YAAY;AAAA,QACZ,UAAU,GAAG,gCAAgC,GAAG,OAAO,GAAG,SAAS,KAAK;AAAA,MAC1E;AAAA,EACJ;AACA,SAAO;AACT;AAMO,SAAS,eACd,UACA,UACQ;AACR,SAAO,KAAK,IAAI,SAAS,mBAAmB,SAAS,iBAAiB,KAAK,KAAK,IAAI,GAAG,WAAW,CAAC,CAAC;AACtG;","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -4,17 +4,20 @@ import {
|
|
|
4
4
|
backoffSeconds,
|
|
5
5
|
checkMessage,
|
|
6
6
|
checkSettings
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-Q5RPIANI.js";
|
|
8
8
|
import {
|
|
9
9
|
FAIL_STATE,
|
|
10
10
|
OUTBOX_CODE,
|
|
11
11
|
OutboxError
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-A6RZGG5L.js";
|
|
13
13
|
|
|
14
14
|
// src/consumer.ts
|
|
15
15
|
import { ident } from "@krizaka/db";
|
|
16
16
|
async function consumeOnce(tx, consumer, messageId, now, effect, schema = "outbox") {
|
|
17
|
-
const rows = await tx(
|
|
17
|
+
const rows = await tx(
|
|
18
|
+
`INSERT INTO ${ident(schema)}.processed (consumer, message_id, processed_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING 1`,
|
|
19
|
+
[consumer, messageId, now]
|
|
20
|
+
);
|
|
18
21
|
if (rows.length === 0) return "duplicate";
|
|
19
22
|
await effect();
|
|
20
23
|
return "applied";
|
|
@@ -29,7 +32,20 @@ function memoryOutboxStore() {
|
|
|
29
32
|
return {
|
|
30
33
|
async append(m) {
|
|
31
34
|
if (rows.has(m.id)) return false;
|
|
32
|
-
rows.set(m.id, {
|
|
35
|
+
rows.set(m.id, {
|
|
36
|
+
id: m.id,
|
|
37
|
+
topic: m.topic,
|
|
38
|
+
type: m.type,
|
|
39
|
+
payload: structuredClone(m.payload),
|
|
40
|
+
createdAt: m.createdAt,
|
|
41
|
+
availableAt: m.availableAt,
|
|
42
|
+
attempts: 0,
|
|
43
|
+
leasedUntil: null,
|
|
44
|
+
leaseOwner: null,
|
|
45
|
+
deliveredAt: null,
|
|
46
|
+
deadAt: null,
|
|
47
|
+
lastError: null
|
|
48
|
+
});
|
|
33
49
|
return true;
|
|
34
50
|
},
|
|
35
51
|
async claim({ topic, owner, limit, leaseSeconds, maxAttempts, now }) {
|
|
@@ -41,12 +57,23 @@ function memoryOutboxStore() {
|
|
|
41
57
|
r.lastError ??= "the last attempt's lease expired with no answer";
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
|
-
const due = [...rows.values()].filter(
|
|
60
|
+
const due = [...rows.values()].filter(
|
|
61
|
+
(r) => open(r) && r.attempts < maxAttempts && !isAfter(r.availableAt, now) && !leaseValid(r, now) && (topic === void 0 || r.topic === topic)
|
|
62
|
+
).sort(
|
|
63
|
+
(a, b) => a.availableAt.valueOf() - b.availableAt.valueOf() || a.createdAt.valueOf() - b.createdAt.valueOf()
|
|
64
|
+
).slice(0, limit);
|
|
45
65
|
return due.map((r) => {
|
|
46
66
|
r.attempts += 1;
|
|
47
67
|
r.leasedUntil = addSeconds(now, leaseSeconds);
|
|
48
68
|
r.leaseOwner = owner;
|
|
49
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
id: r.id,
|
|
71
|
+
topic: r.topic,
|
|
72
|
+
type: r.type,
|
|
73
|
+
payload: structuredClone(r.payload),
|
|
74
|
+
occurredAt: r.createdAt,
|
|
75
|
+
attempts: r.attempts
|
|
76
|
+
};
|
|
50
77
|
});
|
|
51
78
|
},
|
|
52
79
|
async complete(id, owner, now) {
|
|
@@ -137,7 +164,14 @@ function dbOutboxStore(query, schema = "outbox") {
|
|
|
137
164
|
RETURNING m.id, m.topic, m.event_type, m.payload, m.created_at, m.attempts`,
|
|
138
165
|
[addSeconds2(now, leaseSeconds), owner, now, maxAttempts, topic ?? null, limit]
|
|
139
166
|
);
|
|
140
|
-
return rows.map((r) => ({
|
|
167
|
+
return rows.map((r) => ({
|
|
168
|
+
id: r.id,
|
|
169
|
+
topic: r.topic,
|
|
170
|
+
type: r.event_type,
|
|
171
|
+
payload: r.payload,
|
|
172
|
+
occurredAt: new Date(r.created_at),
|
|
173
|
+
attempts: Number(r.attempts)
|
|
174
|
+
})).sort((a, b) => a.occurredAt.valueOf() - b.occurredAt.valueOf());
|
|
141
175
|
},
|
|
142
176
|
async complete(id, owner, now) {
|
|
143
177
|
const rows = await query(
|
|
@@ -161,11 +195,16 @@ function dbOutboxStore(query, schema = "outbox") {
|
|
|
161
195
|
return row.dead_at ? { state: FAIL_STATE.dead } : { state: FAIL_STATE.retry, availableAt: new Date(row.available_at) };
|
|
162
196
|
},
|
|
163
197
|
async retryDead(id, now) {
|
|
164
|
-
const rows = await query(
|
|
198
|
+
const rows = await query(
|
|
199
|
+
`UPDATE ${message} SET dead_at = NULL, attempts = 0, available_at = $2 WHERE id = $1 AND dead_at IS NOT NULL RETURNING 1`,
|
|
200
|
+
[id, now]
|
|
201
|
+
);
|
|
165
202
|
return rows.length > 0;
|
|
166
203
|
},
|
|
167
204
|
async purgeBefore(before) {
|
|
168
|
-
const rows = await query(`DELETE FROM ${message} WHERE COALESCE(delivered_at, dead_at) < $1 RETURNING 1`, [
|
|
205
|
+
const rows = await query(`DELETE FROM ${message} WHERE COALESCE(delivered_at, dead_at) < $1 RETURNING 1`, [
|
|
206
|
+
before
|
|
207
|
+
]);
|
|
169
208
|
return rows.length;
|
|
170
209
|
},
|
|
171
210
|
async stats(now) {
|
|
@@ -177,7 +216,12 @@ function dbOutboxStore(query, schema = "outbox") {
|
|
|
177
216
|
FROM ${message}`,
|
|
178
217
|
[now]
|
|
179
218
|
);
|
|
180
|
-
return {
|
|
219
|
+
return {
|
|
220
|
+
pending: row?.pending ?? 0,
|
|
221
|
+
leased: row?.leased ?? 0,
|
|
222
|
+
delivered: row?.delivered ?? 0,
|
|
223
|
+
dead: row?.dead ?? 0
|
|
224
|
+
};
|
|
181
225
|
}
|
|
182
226
|
};
|
|
183
227
|
}
|
|
@@ -194,17 +238,35 @@ function createOutbox(deps) {
|
|
|
194
238
|
checkMessage(message);
|
|
195
239
|
const now = deps.clock.now();
|
|
196
240
|
const id = message.id ?? randomUUID();
|
|
197
|
-
const appended = await storeFor(tx).append({
|
|
241
|
+
const appended = await storeFor(tx).append({
|
|
242
|
+
id,
|
|
243
|
+
topic: message.topic,
|
|
244
|
+
type: message.type,
|
|
245
|
+
payload: message.payload,
|
|
246
|
+
createdAt: now,
|
|
247
|
+
availableAt: message.availableAt ?? now
|
|
248
|
+
});
|
|
198
249
|
return { id, appended };
|
|
199
250
|
},
|
|
200
251
|
async claim(query, options) {
|
|
201
252
|
const s = await settings();
|
|
202
|
-
return storeFor(query).claim({
|
|
253
|
+
return storeFor(query).claim({
|
|
254
|
+
...options,
|
|
255
|
+
limit: options.limit ?? s.batchSize,
|
|
256
|
+
leaseSeconds: options.leaseSeconds ?? s.leaseSeconds,
|
|
257
|
+
maxAttempts: s.maxAttempts,
|
|
258
|
+
now: deps.clock.now()
|
|
259
|
+
});
|
|
203
260
|
},
|
|
204
261
|
complete: (query, id, owner) => storeFor(query).complete(id, owner, deps.clock.now()),
|
|
205
262
|
async fail(query, id, owner, error) {
|
|
206
263
|
const s = await settings();
|
|
207
|
-
return storeFor(query).fail(id, owner, describe(error), {
|
|
264
|
+
return storeFor(query).fail(id, owner, describe(error), {
|
|
265
|
+
now: deps.clock.now(),
|
|
266
|
+
maxAttempts: s.maxAttempts,
|
|
267
|
+
backoffSeconds: s.backoffSeconds,
|
|
268
|
+
backoffMaxSeconds: s.backoffMaxSeconds
|
|
269
|
+
});
|
|
208
270
|
},
|
|
209
271
|
retryDead: (query, id) => storeFor(query).retryDead(id, deps.clock.now()),
|
|
210
272
|
async purge(query) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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":";;;;;;;;;;;;;;AAAA,SAAS,aAAyB;AAmBlC,eAAsB,YAAY,IAAW,UAAkB,WAAmB,KAAW,QAA6B,SAAS,UAA4C;AAC7K,QAAM,OAAO,MAAM,GAAG,eAAe,MAAM,MAAM,CAAC,0GAA0G,CAAC,UAAU,WAAW,GAAG,CAAC;AACtL,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,OAAO;AACb,SAAO;AACT;;;ACxBA,SAAS,YAAY,SAAS,gBAAgB;AAqBvC,SAAS,oBAAwE;AACtF,QAAM,OAAO,oBAAI,IAAiB;AAClC,QAAM,OAAO,CAAC,MAAW,EAAE,gBAAgB,QAAQ,EAAE,WAAW;AAChE,QAAM,aAAa,CAAC,GAAQ,QAAc,EAAE,gBAAgB,QAAQ,QAAQ,EAAE,aAAa,GAAG;AAC9F,SAAO;AAAA,IACL,MAAM,OAAO,GAAG;AACd,UAAI,KAAK,IAAI,EAAE,EAAE,EAAG,QAAO;AAC3B,WAAK,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,SAAS,gBAAgB,EAAE,OAAO,GAAG,WAAW,EAAE,WAAW,aAAa,EAAE,aAAa,UAAU,GAAG,aAAa,MAAM,YAAY,MAAM,aAAa,MAAM,QAAQ,MAAM,WAAW,KAAK,CAAC;AACtP,aAAO;AAAA,IACT;AAAA,IACA,MAAM,MAAM,EAAE,OAAO,OAAO,OAAO,cAAc,aAAa,IAAI,GAAG;AACnE,iBAAW,KAAK,KAAK,OAAO,GAAG;AAC7B,YAAI,KAAK,CAAC,KAAK,EAAE,YAAY,eAAe,EAAE,gBAAgB,QAAQ,CAAC,WAAW,GAAG,GAAG,GAAG;AACzF,YAAE,SAAS;AACX,YAAE,cAAc;AAChB,YAAE,aAAa;AACf,YAAE,cAAc;AAAA,QAClB;AAAA,MACF;AACA,YAAM,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC,EAC1B,OAAO,CAAC,MAAM,KAAK,CAAC,KAAK,EAAE,WAAW,eAAe,CAAC,QAAQ,EAAE,aAAa,GAAG,KAAK,CAAC,WAAW,GAAG,GAAG,MAAM,UAAU,UAAa,EAAE,UAAU,MAAM,EACtJ,KAAK,CAAC,GAAG,MAAM,EAAE,YAAY,QAAQ,IAAI,EAAE,YAAY,QAAQ,KAAK,EAAE,UAAU,QAAQ,IAAI,EAAE,UAAU,QAAQ,CAAC,EACjH,MAAM,GAAG,KAAK;AACjB,aAAO,IAAI,IAAI,CAAC,MAAqB;AACnC,UAAE,YAAY;AACd,UAAE,cAAc,WAAW,KAAK,YAAY;AAC5C,UAAE,aAAa;AACf,eAAO,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,MAAM,EAAE,MAAM,SAAS,gBAAgB,EAAE,OAAO,GAAG,YAAY,EAAE,WAAW,UAAU,EAAE,SAAS;AAAA,MACtI,CAAC;AAAA,IACH;AAAA,IACA,MAAM,SAAS,IAAI,OAAO,KAAK;AAC7B,YAAM,IAAI,KAAK,IAAI,EAAE;AACrB,UAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,MAAO,QAAO;AACrD,QAAE,cAAc;AAChB,QAAE,cAAc;AAChB,QAAE,aAAa;AACf,aAAO;AAAA,IACT;AAAA,IACA,MAAM,KAAK,IAAI,OAAO,OAAO,EAAE,KAAK,aAAa,gBAAgB,MAAM,kBAAkB,GAAyB;AAChH,YAAM,IAAI,KAAK,IAAI,EAAE;AACrB,UAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,MAAO,QAAO,EAAE,OAAO,WAAW,KAAK;AAC9E,QAAE,YAAY;AACd,QAAE,cAAc;AAChB,QAAE,aAAa;AACf,UAAI,EAAE,YAAY,aAAa;AAC7B,UAAE,SAAS;AACX,eAAO,EAAE,OAAO,WAAW,KAAK;AAAA,MAClC;AACA,QAAE,cAAc,WAAW,KAAK,eAAe,EAAE,UAAU,EAAE,gBAAgB,MAAM,kBAAkB,CAAC,CAAC;AACvG,aAAO,EAAE,OAAO,WAAW,OAAO,aAAa,EAAE,YAAY;AAAA,IAC/D;AAAA,IACA,MAAM,UAAU,IAAI,KAAK;AACvB,YAAM,IAAI,KAAK,IAAI,EAAE;AACrB,UAAI,CAAC,KAAK,EAAE,WAAW,KAAM,QAAO;AACpC,QAAE,SAAS;AACX,QAAE,WAAW;AACb,QAAE,cAAc;AAChB,aAAO;AAAA,IACT;AAAA,IACA,MAAM,YAAY,QAAQ;AACxB,UAAI,UAAU;AACd,iBAAW,CAAC,IAAI,CAAC,KAAK,MAAM;AAC1B,cAAM,WAAW,EAAE,eAAe,EAAE;AACpC,YAAI,aAAa,QAAQ,SAAS,UAAU,MAAM,GAAG;AACnD,eAAK,OAAO,EAAE;AACd,qBAAW;AAAA,QACb;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAAA,IACA,MAAM,MAAM,KAA2B;AACrC,YAAM,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC;AAC7B,aAAO;AAAA,QACL,SAAS,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,KAAK,CAAC,WAAW,GAAG,GAAG,CAAC,EAAE;AAAA,QAC3D,QAAQ,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,KAAK,WAAW,GAAG,GAAG,CAAC,EAAE;AAAA,QACzD,WAAW,IAAI,OAAO,CAAC,MAAM,EAAE,gBAAgB,IAAI,EAAE;AAAA,QACrD,MAAM,IAAI,OAAO,CAAC,MAAM,EAAE,WAAW,IAAI,EAAE;AAAA,MAC7C;AAAA,IACF;AAAA,IACA,MAAM,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC;AAAA,EAC/B;AACF;;;ACtGA,SAAS,kBAAkB;AAE3B,SAAS,SAAAA,cAAa;AACtB,SAAS,gBAAgB;;;ACHzB,SAAS,SAAAC,cAAyB;AAClC,SAAS,cAAAC,mBAAkB;AAsBpB,SAAS,cAAc,OAAc,SAAS,UAAuB;AAC1E,QAAM,UAAU,GAAGC,OAAM,MAAM,CAAC;AAChC,SAAO;AAAA,IACL,MAAM,OAAO,GAAG;AACd,YAAM,OAAO,MAAM;AAAA,QACjB,eAAe,OAAO;AAAA,QACtB,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,KAAK,UAAU,EAAE,OAAO,GAAG,EAAE,WAAW,EAAE,WAAW;AAAA,MAC/E;AACA,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,MAAM,MAAM,EAAE,OAAO,OAAO,OAAO,cAAc,aAAa,IAAI,GAAG;AAEnE,YAAM;AAAA,QACJ,UAAU,OAAO;AAAA;AAAA,QAEjB,CAAC,KAAK,WAAW;AAAA,MACnB;AACA,YAAM,OAAO,MAAM;AAAA,QACjB,UAAU,OAAO;AAAA;AAAA,6BAEI,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAM5B,CAACC,YAAW,KAAK,YAAY,GAAG,OAAO,KAAK,aAAa,SAAS,MAAM,KAAK;AAAA,MAC/E;AACA,aAAO,KACJ,IAAI,CAAC,OAAsB,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,MAAM,EAAE,YAAY,SAAS,EAAE,SAAS,YAAY,IAAI,KAAK,EAAE,UAAU,GAAG,UAAU,OAAO,EAAE,QAAQ,EAAE,EAAE,EAClK,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,QAAQ,IAAI,EAAE,WAAW,QAAQ,CAAC;AAAA,IACnE;AAAA,IACA,MAAM,SAAS,IAAI,OAAO,KAAK;AAC7B,YAAM,OAAO,MAAM;AAAA,QACjB,UAAU,OAAO;AAAA;AAAA,QAEjB,CAAC,IAAI,OAAO,GAAG;AAAA,MACjB;AACA,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,MAAM,KAAK,IAAI,OAAO,OAAO,EAAE,KAAK,aAAa,gBAAAC,iBAAgB,kBAAkB,GAAyB;AAC1G,YAAM,CAAC,GAAG,IAAI,MAAM;AAAA,QAClB,UAAU,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMjB,CAAC,IAAI,OAAO,OAAO,KAAK,aAAaA,iBAAgB,iBAAiB;AAAA,MACxE;AACA,UAAI,CAAC,IAAK,QAAO,EAAE,OAAO,WAAW,KAAK;AAC1C,aAAO,IAAI,UAAU,EAAE,OAAO,WAAW,KAAK,IAAI,EAAE,OAAO,WAAW,OAAO,aAAa,IAAI,KAAK,IAAI,YAAY,EAAE;AAAA,IACvH;AAAA,IACA,MAAM,UAAU,IAAI,KAAK;AACvB,YAAM,OAAO,MAAM,MAAM,UAAU,OAAO,0GAA0G,CAAC,IAAI,GAAG,CAAC;AAC7J,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,MAAM,YAAY,QAAQ;AACxB,YAAM,OAAO,MAAM,MAAM,eAAe,OAAO,2DAA2D,CAAC,MAAM,CAAC;AAClH,aAAO,KAAK;AAAA,IACd;AAAA,IACA,MAAM,MAAM,KAA2B;AACrC,YAAM,CAAC,GAAG,IAAI,MAAM;AAAA,QAClB;AAAA;AAAA;AAAA;AAAA,kBAIU,OAAO;AAAA,QACjB,CAAC,GAAG;AAAA,MACN;AACA,aAAO,EAAE,SAAS,KAAK,WAAW,GAAG,QAAQ,KAAK,UAAU,GAAG,WAAW,KAAK,aAAa,GAAG,MAAM,KAAK,QAAQ,EAAE;AAAA,IACtH;AAAA,EACF;AACF;;;AD5DA,IAAM,YAAY;AAClB,IAAM,WAAW,CAAC,WAAoB,iBAAiB,QAAQ,GAAG,MAAM,IAAI,KAAK,MAAM,OAAO,KAAK,OAAO,KAAK,GAAG,MAAM,GAAG,SAAS;AAO7H,SAAS,aAAa,MAA0B;AACrD,QAAM,SAASC,OAAM,KAAK,UAAU,QAAQ;AAC5C,QAAM,WAAW,KAAK,aAAa,CAAC,UAAiB,cAAc,OAAO,MAAM;AAChF,QAAM,WAAW,YAAY,cAAc,OAAO,KAAK,aAAa,aAAa,MAAM,KAAK,SAAS,IAAI,KAAK,QAAQ;AACtH,SAAO;AAAA,IACL,MAAM,OAAO,IAAI,SAAS;AACxB,mBAAa,OAAO;AACpB,YAAM,MAAM,KAAK,MAAM,IAAI;AAC3B,YAAM,KAAK,QAAQ,MAAM,WAAW;AACpC,YAAM,WAAW,MAAM,SAAS,EAAE,EAAE,OAAO,EAAE,IAAI,OAAO,QAAQ,OAAO,MAAM,QAAQ,MAAM,SAAS,QAAQ,SAAS,WAAW,KAAK,aAAa,QAAQ,eAAe,IAAI,CAAC;AAC9K,aAAO,EAAE,IAAI,SAAS;AAAA,IACxB;AAAA,IACA,MAAM,MAAM,OAAO,SAAS;AAC1B,YAAM,IAAI,MAAM,SAAS;AACzB,aAAO,SAAS,KAAK,EAAE,MAAM,EAAE,GAAG,SAAS,OAAO,QAAQ,SAAS,EAAE,WAAW,cAAc,QAAQ,gBAAgB,EAAE,cAAc,aAAa,EAAE,aAAa,KAAK,KAAK,MAAM,IAAI,EAAE,CAAC;AAAA,IAC3L;AAAA,IACA,UAAU,CAAC,OAAO,IAAI,UAAU,SAAS,KAAK,EAAE,SAAS,IAAI,OAAO,KAAK,MAAM,IAAI,CAAC;AAAA,IACpF,MAAM,KAAK,OAAO,IAAI,OAAO,OAAO;AAClC,YAAM,IAAI,MAAM,SAAS;AACzB,aAAO,SAAS,KAAK,EAAE,KAAK,IAAI,OAAO,SAAS,KAAK,GAAG,EAAE,KAAK,KAAK,MAAM,IAAI,GAAG,aAAa,EAAE,aAAa,gBAAgB,EAAE,gBAAgB,mBAAmB,EAAE,kBAAkB,CAAC;AAAA,IACzL;AAAA,IACA,WAAW,CAAC,OAAO,OAAO,SAAS,KAAK,EAAE,UAAU,IAAI,KAAK,MAAM,IAAI,CAAC;AAAA,IACxE,MAAM,MAAM,OAAO;AACjB,YAAM,IAAI,MAAM,SAAS;AACzB,aAAO,SAAS,KAAK,EAAE,YAAY,SAAS,KAAK,MAAM,IAAI,GAAG,EAAE,gBAAgB,EAAE,CAAC;AAAA,IACrF;AAAA,IACA,OAAO,CAAC,UAAU,SAAS,KAAK,EAAE,MAAM,KAAK,MAAM,IAAI,CAAC;AAAA,EAC1D;AACF;;;AExEA,SAAS,oBAAoB;AAOtB,IAAM,eAAe,aAAa;AAAA,EACvC,QAAQ;AAAA,EACR,YAAY;AAAA,IACV;AAAA,MACE,SAAS;AAAA,MACT,MAAM;AAAA,MACN,KAAK,CAAC,MAAM;AAAA,eACH,EAAE,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8BAeO,EAAE,MAAM;AAAA,mCACH,EAAE,MAAM;AAAA,eAC5B,EAAE,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnB;AAAA,EACF;AACF,CAAC;;;ACVD,eAAsB,UAAU,SAA8C;AAC5E,QAAM,EAAE,QAAQ,OAAO,OAAO,SAAS,MAAM,IAAI;AACjD,QAAM,UAAwB,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,EAAE;AACvF,QAAM,QAAQ,MAAM,OAAO,MAAM,OAAO,EAAE,OAAO,GAAI,UAAU,SAAY,CAAC,IAAI,EAAE,MAAM,EAAG,CAAC;AAC5F,UAAQ,UAAU,MAAM;AACxB,aAAW,WAAW,OAAO;AAC3B,QAAI,UAAqC;AACzC,QAAI;AACF,YAAM,QAAQ,OAAO;AAAA,IACvB,SAAS,OAAO;AACd,gBAAU,EAAE,MAAM;AAAA,IACpB;AACA,QAAI,YAAY,MAAM;AACpB,UAAI,MAAM,OAAO,SAAS,OAAO,QAAQ,IAAI,KAAK,EAAG,SAAQ,aAAa;AAAA,UACrE,SAAQ,QAAQ;AACrB;AAAA,IACF;AACA,UAAM,UAAU,MAAM,OAAO,KAAK,OAAO,QAAQ,IAAI,OAAO,QAAQ,KAAK;AACzE,QAAI,QAAQ,UAAU,WAAW,MAAO,SAAQ,WAAW;AAAA,aAClD,QAAQ,UAAU,WAAW,KAAM,SAAQ,QAAQ;AAAA,QACvD,SAAQ,QAAQ;AAAA,EACvB;AACA,SAAO;AACT;AAcA,IAAM,eAAe;AAMrB,eAAsB,UAAU,SAAuC;AACrE,QAAM,OAAO,QAAQ,eAAe;AACpC,SAAO,CAAC,QAAQ,QAAQ,SAAS;AAC/B,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,UAAU,OAAO,GAAG,YAAY;AAAA,IAClD,SAAS,OAAO;AACd,cAAQ,UAAU,KAAK;AACvB,eAAS;AAAA,IACX;AACA,QAAI,UAAU,CAAC,QAAQ,QAAQ,QAAS,OAAM,QAAQ,MAAM,IAAI;AAAA,EAClE;AACF;","names":["ident","ident","addSeconds","ident","addSeconds","backoffSeconds","ident"]}
|
|
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(\n tx: Query,\n consumer: string,\n messageId: string,\n now: Date,\n effect: () => Promise<void>,\n schema = \"outbox\",\n): Promise<\"applied\" | \"duplicate\"> {\n const rows = await tx(\n `INSERT INTO ${ident(schema)}.processed (consumer, message_id, processed_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING 1`,\n [consumer, messageId, now],\n );\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, {\n id: m.id,\n topic: m.topic,\n type: m.type,\n payload: structuredClone(m.payload),\n createdAt: m.createdAt,\n availableAt: m.availableAt,\n attempts: 0,\n leasedUntil: null,\n leaseOwner: null,\n deliveredAt: null,\n deadAt: null,\n lastError: null,\n });\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(\n (r) =>\n open(r) &&\n r.attempts < maxAttempts &&\n !isAfter(r.availableAt, now) &&\n !leaseValid(r, now) &&\n (topic === undefined || r.topic === topic),\n )\n .sort(\n (a, b) => a.availableAt.valueOf() - b.availableAt.valueOf() || a.createdAt.valueOf() - b.createdAt.valueOf(),\n )\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 {\n id: r.id,\n topic: r.topic,\n type: r.type,\n payload: structuredClone(r.payload),\n occurredAt: r.createdAt,\n attempts: r.attempts,\n };\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 {\n type ClaimOptions,\n type Clock,\n type FailOutcome,\n type NewMessage,\n type OutboxMessage,\n type OutboxSettings,\n type OutboxStats,\n type OutboxStore,\n type Query,\n} 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) =>\n (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 () =>\n 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({\n id,\n topic: message.topic,\n type: message.type,\n payload: message.payload,\n createdAt: now,\n availableAt: message.availableAt ?? now,\n });\n return { id, appended };\n },\n async claim(query, options) {\n const s = await settings();\n return storeFor(query).claim({\n ...options,\n limit: options.limit ?? s.batchSize,\n leaseSeconds: options.leaseSeconds ?? s.leaseSeconds,\n maxAttempts: s.maxAttempts,\n now: deps.clock.now(),\n });\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), {\n now: deps.clock.now(),\n maxAttempts: s.maxAttempts,\n backoffSeconds: s.backoffSeconds,\n backoffMaxSeconds: s.backoffMaxSeconds,\n });\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 => ({\n id: r.id,\n topic: r.topic,\n type: r.event_type,\n payload: r.payload,\n occurredAt: new Date(r.created_at),\n attempts: Number(r.attempts),\n }))\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\n ? { state: FAIL_STATE.dead }\n : { state: FAIL_STATE.retry, availableAt: new Date(row.available_at) };\n },\n async retryDead(id, now) {\n const rows = await query(\n `UPDATE ${message} SET dead_at = NULL, attempts = 0, available_at = $2 WHERE id = $1 AND dead_at IS NOT NULL RETURNING 1`,\n [id, now],\n );\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`, [\n before,\n ]);\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 {\n pending: row?.pending ?? 0,\n leased: row?.leased ?? 0,\n delivered: row?.delivered ?? 0,\n dead: row?.dead ?? 0,\n };\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":";;;;;;;;;;;;;;AAAA,SAAS,aAAyB;AAmBlC,eAAsB,YACpB,IACA,UACA,WACA,KACA,QACA,SAAS,UACyB;AAClC,QAAM,OAAO,MAAM;AAAA,IACjB,eAAe,MAAM,MAAM,CAAC;AAAA,IAC5B,CAAC,UAAU,WAAW,GAAG;AAAA,EAC3B;AACA,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,OAAO;AACb,SAAO;AACT;;;AClCA,SAAS,YAAY,SAAS,gBAAgB;AAqBvC,SAAS,oBAAwE;AACtF,QAAM,OAAO,oBAAI,IAAiB;AAClC,QAAM,OAAO,CAAC,MAAW,EAAE,gBAAgB,QAAQ,EAAE,WAAW;AAChE,QAAM,aAAa,CAAC,GAAQ,QAAc,EAAE,gBAAgB,QAAQ,QAAQ,EAAE,aAAa,GAAG;AAC9F,SAAO;AAAA,IACL,MAAM,OAAO,GAAG;AACd,UAAI,KAAK,IAAI,EAAE,EAAE,EAAG,QAAO;AAC3B,WAAK,IAAI,EAAE,IAAI;AAAA,QACb,IAAI,EAAE;AAAA,QACN,OAAO,EAAE;AAAA,QACT,MAAM,EAAE;AAAA,QACR,SAAS,gBAAgB,EAAE,OAAO;AAAA,QAClC,WAAW,EAAE;AAAA,QACb,aAAa,EAAE;AAAA,QACf,UAAU;AAAA,QACV,aAAa;AAAA,QACb,YAAY;AAAA,QACZ,aAAa;AAAA,QACb,QAAQ;AAAA,QACR,WAAW;AAAA,MACb,CAAC;AACD,aAAO;AAAA,IACT;AAAA,IACA,MAAM,MAAM,EAAE,OAAO,OAAO,OAAO,cAAc,aAAa,IAAI,GAAG;AACnE,iBAAW,KAAK,KAAK,OAAO,GAAG;AAC7B,YAAI,KAAK,CAAC,KAAK,EAAE,YAAY,eAAe,EAAE,gBAAgB,QAAQ,CAAC,WAAW,GAAG,GAAG,GAAG;AACzF,YAAE,SAAS;AACX,YAAE,cAAc;AAChB,YAAE,aAAa;AACf,YAAE,cAAc;AAAA,QAClB;AAAA,MACF;AACA,YAAM,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC,EAC1B;AAAA,QACC,CAAC,MACC,KAAK,CAAC,KACN,EAAE,WAAW,eACb,CAAC,QAAQ,EAAE,aAAa,GAAG,KAC3B,CAAC,WAAW,GAAG,GAAG,MACjB,UAAU,UAAa,EAAE,UAAU;AAAA,MACxC,EACC;AAAA,QACC,CAAC,GAAG,MAAM,EAAE,YAAY,QAAQ,IAAI,EAAE,YAAY,QAAQ,KAAK,EAAE,UAAU,QAAQ,IAAI,EAAE,UAAU,QAAQ;AAAA,MAC7G,EACC,MAAM,GAAG,KAAK;AACjB,aAAO,IAAI,IAAI,CAAC,MAAqB;AACnC,UAAE,YAAY;AACd,UAAE,cAAc,WAAW,KAAK,YAAY;AAC5C,UAAE,aAAa;AACf,eAAO;AAAA,UACL,IAAI,EAAE;AAAA,UACN,OAAO,EAAE;AAAA,UACT,MAAM,EAAE;AAAA,UACR,SAAS,gBAAgB,EAAE,OAAO;AAAA,UAClC,YAAY,EAAE;AAAA,UACd,UAAU,EAAE;AAAA,QACd;AAAA,MACF,CAAC;AAAA,IACH;AAAA,IACA,MAAM,SAAS,IAAI,OAAO,KAAK;AAC7B,YAAM,IAAI,KAAK,IAAI,EAAE;AACrB,UAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,MAAO,QAAO;AACrD,QAAE,cAAc;AAChB,QAAE,cAAc;AAChB,QAAE,aAAa;AACf,aAAO;AAAA,IACT;AAAA,IACA,MAAM,KAAK,IAAI,OAAO,OAAO,EAAE,KAAK,aAAa,gBAAgB,MAAM,kBAAkB,GAAyB;AAChH,YAAM,IAAI,KAAK,IAAI,EAAE;AACrB,UAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,EAAE,eAAe,MAAO,QAAO,EAAE,OAAO,WAAW,KAAK;AAC9E,QAAE,YAAY;AACd,QAAE,cAAc;AAChB,QAAE,aAAa;AACf,UAAI,EAAE,YAAY,aAAa;AAC7B,UAAE,SAAS;AACX,eAAO,EAAE,OAAO,WAAW,KAAK;AAAA,MAClC;AACA,QAAE,cAAc,WAAW,KAAK,eAAe,EAAE,UAAU,EAAE,gBAAgB,MAAM,kBAAkB,CAAC,CAAC;AACvG,aAAO,EAAE,OAAO,WAAW,OAAO,aAAa,EAAE,YAAY;AAAA,IAC/D;AAAA,IACA,MAAM,UAAU,IAAI,KAAK;AACvB,YAAM,IAAI,KAAK,IAAI,EAAE;AACrB,UAAI,CAAC,KAAK,EAAE,WAAW,KAAM,QAAO;AACpC,QAAE,SAAS;AACX,QAAE,WAAW;AACb,QAAE,cAAc;AAChB,aAAO;AAAA,IACT;AAAA,IACA,MAAM,YAAY,QAAQ;AACxB,UAAI,UAAU;AACd,iBAAW,CAAC,IAAI,CAAC,KAAK,MAAM;AAC1B,cAAM,WAAW,EAAE,eAAe,EAAE;AACpC,YAAI,aAAa,QAAQ,SAAS,UAAU,MAAM,GAAG;AACnD,eAAK,OAAO,EAAE;AACd,qBAAW;AAAA,QACb;AAAA,MACF;AACA,aAAO;AAAA,IACT;AAAA,IACA,MAAM,MAAM,KAA2B;AACrC,YAAM,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC;AAC7B,aAAO;AAAA,QACL,SAAS,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,KAAK,CAAC,WAAW,GAAG,GAAG,CAAC,EAAE;AAAA,QAC3D,QAAQ,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,KAAK,WAAW,GAAG,GAAG,CAAC,EAAE;AAAA,QACzD,WAAW,IAAI,OAAO,CAAC,MAAM,EAAE,gBAAgB,IAAI,EAAE;AAAA,QACrD,MAAM,IAAI,OAAO,CAAC,MAAM,EAAE,WAAW,IAAI,EAAE;AAAA,MAC7C;AAAA,IACF;AAAA,IACA,MAAM,MAAM,CAAC,GAAG,KAAK,OAAO,CAAC;AAAA,EAC/B;AACF;;;ACnIA,SAAS,kBAAkB;AAE3B,SAAS,SAAAA,cAAa;AACtB,SAAS,gBAAgB;;;ACHzB,SAAS,SAAAC,cAAyB;AAClC,SAAS,cAAAC,mBAAkB;AAsBpB,SAAS,cAAc,OAAc,SAAS,UAAuB;AAC1E,QAAM,UAAU,GAAGC,OAAM,MAAM,CAAC;AAChC,SAAO;AAAA,IACL,MAAM,OAAO,GAAG;AACd,YAAM,OAAO,MAAM;AAAA,QACjB,eAAe,OAAO;AAAA,QACtB,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,KAAK,UAAU,EAAE,OAAO,GAAG,EAAE,WAAW,EAAE,WAAW;AAAA,MAC/E;AACA,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,MAAM,MAAM,EAAE,OAAO,OAAO,OAAO,cAAc,aAAa,IAAI,GAAG;AAEnE,YAAM;AAAA,QACJ,UAAU,OAAO;AAAA;AAAA,QAEjB,CAAC,KAAK,WAAW;AAAA,MACnB;AACA,YAAM,OAAO,MAAM;AAAA,QACjB,UAAU,OAAO;AAAA;AAAA,6BAEI,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAM5B,CAACC,YAAW,KAAK,YAAY,GAAG,OAAO,KAAK,aAAa,SAAS,MAAM,KAAK;AAAA,MAC/E;AACA,aAAO,KACJ,IAAI,CAAC,OAAsB;AAAA,QAC1B,IAAI,EAAE;AAAA,QACN,OAAO,EAAE;AAAA,QACT,MAAM,EAAE;AAAA,QACR,SAAS,EAAE;AAAA,QACX,YAAY,IAAI,KAAK,EAAE,UAAU;AAAA,QACjC,UAAU,OAAO,EAAE,QAAQ;AAAA,MAC7B,EAAE,EACD,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,QAAQ,IAAI,EAAE,WAAW,QAAQ,CAAC;AAAA,IACnE;AAAA,IACA,MAAM,SAAS,IAAI,OAAO,KAAK;AAC7B,YAAM,OAAO,MAAM;AAAA,QACjB,UAAU,OAAO;AAAA;AAAA,QAEjB,CAAC,IAAI,OAAO,GAAG;AAAA,MACjB;AACA,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,MAAM,KAAK,IAAI,OAAO,OAAO,EAAE,KAAK,aAAa,gBAAAC,iBAAgB,kBAAkB,GAAyB;AAC1G,YAAM,CAAC,GAAG,IAAI,MAAM;AAAA,QAClB,UAAU,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMjB,CAAC,IAAI,OAAO,OAAO,KAAK,aAAaA,iBAAgB,iBAAiB;AAAA,MACxE;AACA,UAAI,CAAC,IAAK,QAAO,EAAE,OAAO,WAAW,KAAK;AAC1C,aAAO,IAAI,UACP,EAAE,OAAO,WAAW,KAAK,IACzB,EAAE,OAAO,WAAW,OAAO,aAAa,IAAI,KAAK,IAAI,YAAY,EAAE;AAAA,IACzE;AAAA,IACA,MAAM,UAAU,IAAI,KAAK;AACvB,YAAM,OAAO,MAAM;AAAA,QACjB,UAAU,OAAO;AAAA,QACjB,CAAC,IAAI,GAAG;AAAA,MACV;AACA,aAAO,KAAK,SAAS;AAAA,IACvB;AAAA,IACA,MAAM,YAAY,QAAQ;AACxB,YAAM,OAAO,MAAM,MAAM,eAAe,OAAO,2DAA2D;AAAA,QACxG;AAAA,MACF,CAAC;AACD,aAAO,KAAK;AAAA,IACd;AAAA,IACA,MAAM,MAAM,KAA2B;AACrC,YAAM,CAAC,GAAG,IAAI,MAAM;AAAA,QAClB;AAAA;AAAA;AAAA;AAAA,kBAIU,OAAO;AAAA,QACjB,CAAC,GAAG;AAAA,MACN;AACA,aAAO;AAAA,QACL,SAAS,KAAK,WAAW;AAAA,QACzB,QAAQ,KAAK,UAAU;AAAA,QACvB,WAAW,KAAK,aAAa;AAAA,QAC7B,MAAM,KAAK,QAAQ;AAAA,MACrB;AAAA,IACF;AAAA,EACF;AACF;;;ADrEA,IAAM,YAAY;AAClB,IAAM,WAAW,CAAC,WACf,iBAAiB,QAAQ,GAAG,MAAM,IAAI,KAAK,MAAM,OAAO,KAAK,OAAO,KAAK,GAAG,MAAM,GAAG,SAAS;AAO1F,SAAS,aAAa,MAA0B;AACrD,QAAM,SAASC,OAAM,KAAK,UAAU,QAAQ;AAC5C,QAAM,WAAW,KAAK,aAAa,CAAC,UAAiB,cAAc,OAAO,MAAM;AAChF,QAAM,WAAW,YACf,cAAc,OAAO,KAAK,aAAa,aAAa,MAAM,KAAK,SAAS,IAAI,KAAK,QAAQ;AAC3F,SAAO;AAAA,IACL,MAAM,OAAO,IAAI,SAAS;AACxB,mBAAa,OAAO;AACpB,YAAM,MAAM,KAAK,MAAM,IAAI;AAC3B,YAAM,KAAK,QAAQ,MAAM,WAAW;AACpC,YAAM,WAAW,MAAM,SAAS,EAAE,EAAE,OAAO;AAAA,QACzC;AAAA,QACA,OAAO,QAAQ;AAAA,QACf,MAAM,QAAQ;AAAA,QACd,SAAS,QAAQ;AAAA,QACjB,WAAW;AAAA,QACX,aAAa,QAAQ,eAAe;AAAA,MACtC,CAAC;AACD,aAAO,EAAE,IAAI,SAAS;AAAA,IACxB;AAAA,IACA,MAAM,MAAM,OAAO,SAAS;AAC1B,YAAM,IAAI,MAAM,SAAS;AACzB,aAAO,SAAS,KAAK,EAAE,MAAM;AAAA,QAC3B,GAAG;AAAA,QACH,OAAO,QAAQ,SAAS,EAAE;AAAA,QAC1B,cAAc,QAAQ,gBAAgB,EAAE;AAAA,QACxC,aAAa,EAAE;AAAA,QACf,KAAK,KAAK,MAAM,IAAI;AAAA,MACtB,CAAC;AAAA,IACH;AAAA,IACA,UAAU,CAAC,OAAO,IAAI,UAAU,SAAS,KAAK,EAAE,SAAS,IAAI,OAAO,KAAK,MAAM,IAAI,CAAC;AAAA,IACpF,MAAM,KAAK,OAAO,IAAI,OAAO,OAAO;AAClC,YAAM,IAAI,MAAM,SAAS;AACzB,aAAO,SAAS,KAAK,EAAE,KAAK,IAAI,OAAO,SAAS,KAAK,GAAG;AAAA,QACtD,KAAK,KAAK,MAAM,IAAI;AAAA,QACpB,aAAa,EAAE;AAAA,QACf,gBAAgB,EAAE;AAAA,QAClB,mBAAmB,EAAE;AAAA,MACvB,CAAC;AAAA,IACH;AAAA,IACA,WAAW,CAAC,OAAO,OAAO,SAAS,KAAK,EAAE,UAAU,IAAI,KAAK,MAAM,IAAI,CAAC;AAAA,IACxE,MAAM,MAAM,OAAO;AACjB,YAAM,IAAI,MAAM,SAAS;AACzB,aAAO,SAAS,KAAK,EAAE,YAAY,SAAS,KAAK,MAAM,IAAI,GAAG,EAAE,gBAAgB,EAAE,CAAC;AAAA,IACrF;AAAA,IACA,OAAO,CAAC,UAAU,SAAS,KAAK,EAAE,MAAM,KAAK,MAAM,IAAI,CAAC;AAAA,EAC1D;AACF;;;AEtGA,SAAS,oBAAoB;AAOtB,IAAM,eAAe,aAAa;AAAA,EACvC,QAAQ;AAAA,EACR,YAAY;AAAA,IACV;AAAA,MACE,SAAS;AAAA,MACT,MAAM;AAAA,MACN,KAAK,CAAC,MAAM;AAAA,eACH,EAAE,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8BAeO,EAAE,MAAM;AAAA,mCACH,EAAE,MAAM;AAAA,eAC5B,EAAE,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnB;AAAA,EACF;AACF,CAAC;;;ACVD,eAAsB,UAAU,SAA8C;AAC5E,QAAM,EAAE,QAAQ,OAAO,OAAO,SAAS,MAAM,IAAI;AACjD,QAAM,UAAwB,EAAE,SAAS,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,EAAE;AACvF,QAAM,QAAQ,MAAM,OAAO,MAAM,OAAO,EAAE,OAAO,GAAI,UAAU,SAAY,CAAC,IAAI,EAAE,MAAM,EAAG,CAAC;AAC5F,UAAQ,UAAU,MAAM;AACxB,aAAW,WAAW,OAAO;AAC3B,QAAI,UAAqC;AACzC,QAAI;AACF,YAAM,QAAQ,OAAO;AAAA,IACvB,SAAS,OAAO;AACd,gBAAU,EAAE,MAAM;AAAA,IACpB;AACA,QAAI,YAAY,MAAM;AACpB,UAAI,MAAM,OAAO,SAAS,OAAO,QAAQ,IAAI,KAAK,EAAG,SAAQ,aAAa;AAAA,UACrE,SAAQ,QAAQ;AACrB;AAAA,IACF;AACA,UAAM,UAAU,MAAM,OAAO,KAAK,OAAO,QAAQ,IAAI,OAAO,QAAQ,KAAK;AACzE,QAAI,QAAQ,UAAU,WAAW,MAAO,SAAQ,WAAW;AAAA,aAClD,QAAQ,UAAU,WAAW,KAAM,SAAQ,QAAQ;AAAA,QACvD,SAAQ,QAAQ;AAAA,EACvB;AACA,SAAO;AACT;AAcA,IAAM,eAAe;AAMrB,eAAsB,UAAU,SAAuC;AACrE,QAAM,OAAO,QAAQ,eAAe;AACpC,SAAO,CAAC,QAAQ,QAAQ,SAAS;AAC/B,QAAI;AACJ,QAAI;AACF,gBAAU,MAAM,UAAU,OAAO,GAAG,YAAY;AAAA,IAClD,SAAS,OAAO;AACd,cAAQ,UAAU,KAAK;AACvB,eAAS;AAAA,IACX;AACA,QAAI,UAAU,CAAC,QAAQ,QAAQ,QAAS,OAAM,QAAQ,MAAM,IAAI;AAAA,EAClE;AACF;","names":["ident","ident","addSeconds","ident","addSeconds","backoffSeconds","ident"]}
|
package/dist/settings.js
CHANGED
package/dist/settings.js.map
CHANGED
|
@@ -1 +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)
|
|
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) =>\n 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(\n read: (key: keyof typeof outboxSettings) => number | undefined | Promise<number | undefined>,\n): 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,SACnD,QAAQ,UAAU,gBAAgB,GAAG,GAAG,EAAE,KAAK,cAAc,GAAG,EAAE,CAAC,GAAG,KAAK,cAAc,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC;AAMnG,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,mBACpB,MACyB;AACzB,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":[]}
|
package/dist/testkit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
FAIL_STATE
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-A6RZGG5L.js";
|
|
4
4
|
|
|
5
5
|
// src/testkit.ts
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
@@ -8,17 +8,49 @@ import { expect, it } from "vitest";
|
|
|
8
8
|
var T0 = /* @__PURE__ */ new Date("2026-10-26T12:00:00.000Z");
|
|
9
9
|
var at = (seconds) => new Date(T0.valueOf() + seconds * 1e3);
|
|
10
10
|
var RULES = { maxAttempts: 3, backoffSeconds: 30, backoffMaxSeconds: 100 };
|
|
11
|
-
var claimFor = (store, owner, seconds, over = {}) => store.claim({
|
|
11
|
+
var claimFor = (store, owner, seconds, over = {}) => store.claim({
|
|
12
|
+
owner,
|
|
13
|
+
limit: over.limit ?? 10,
|
|
14
|
+
leaseSeconds: over.lease ?? 60,
|
|
15
|
+
maxAttempts: RULES.maxAttempts,
|
|
16
|
+
now: at(seconds),
|
|
17
|
+
...over.topic === void 0 ? {} : { topic: over.topic }
|
|
18
|
+
});
|
|
12
19
|
var put = (store, over = {}) => {
|
|
13
20
|
const id = over.id ?? randomUUID();
|
|
14
|
-
return store.append({
|
|
21
|
+
return store.append({
|
|
22
|
+
id,
|
|
23
|
+
topic: over.topic ?? "mail",
|
|
24
|
+
type: "evt.mail.requested.v1",
|
|
25
|
+
payload: over.payload ?? { to: "a@b.c" },
|
|
26
|
+
createdAt: T0,
|
|
27
|
+
availableAt: over.availableAt ?? T0
|
|
28
|
+
}).then(() => id);
|
|
15
29
|
};
|
|
16
30
|
function outboxStoreContract(name, make) {
|
|
17
31
|
it(`${name}: appends a message once, whatever the number of appends of its id`, async () => {
|
|
18
32
|
const store = await make();
|
|
19
33
|
const id = randomUUID();
|
|
20
|
-
expect(
|
|
21
|
-
|
|
34
|
+
expect(
|
|
35
|
+
await store.append({
|
|
36
|
+
id,
|
|
37
|
+
topic: "mail",
|
|
38
|
+
type: "evt.mail.requested.v1",
|
|
39
|
+
payload: { n: 1 },
|
|
40
|
+
createdAt: T0,
|
|
41
|
+
availableAt: T0
|
|
42
|
+
})
|
|
43
|
+
).toBe(true);
|
|
44
|
+
expect(
|
|
45
|
+
await store.append({
|
|
46
|
+
id,
|
|
47
|
+
topic: "mail",
|
|
48
|
+
type: "evt.mail.requested.v1",
|
|
49
|
+
payload: { n: 2 },
|
|
50
|
+
createdAt: T0,
|
|
51
|
+
availableAt: T0
|
|
52
|
+
})
|
|
53
|
+
).toBe(false);
|
|
22
54
|
const [only, ...rest] = await claimFor(store, "w1", 0);
|
|
23
55
|
expect(rest).toEqual([]);
|
|
24
56
|
expect(only).toMatchObject({ id, topic: "mail", type: "evt.mail.requested.v1", payload: { n: 1 }, attempts: 1 });
|
|
@@ -52,10 +84,16 @@ function outboxStoreContract(name, make) {
|
|
|
52
84
|
const store = await make();
|
|
53
85
|
const id = await put(store);
|
|
54
86
|
await claimFor(store, "w", 0);
|
|
55
|
-
expect(await store.fail(id, "w", "boom", { now: at(1), ...RULES })).toEqual({
|
|
87
|
+
expect(await store.fail(id, "w", "boom", { now: at(1), ...RULES })).toEqual({
|
|
88
|
+
state: "retry",
|
|
89
|
+
availableAt: at(31)
|
|
90
|
+
});
|
|
56
91
|
expect(await claimFor(store, "w", 30)).toEqual([]);
|
|
57
92
|
expect((await claimFor(store, "w", 31))[0]).toMatchObject({ id, attempts: 2 });
|
|
58
|
-
expect(await store.fail(id, "w", "boom", { now: at(40), ...RULES })).toEqual({
|
|
93
|
+
expect(await store.fail(id, "w", "boom", { now: at(40), ...RULES })).toEqual({
|
|
94
|
+
state: "retry",
|
|
95
|
+
availableAt: at(100)
|
|
96
|
+
});
|
|
59
97
|
expect((await claimFor(store, "w", 100))[0]).toMatchObject({ id, attempts: 3 });
|
|
60
98
|
expect(await store.fail(id, "w", "boom", { now: at(110), ...RULES })).toEqual({ state: "dead" });
|
|
61
99
|
expect(await claimFor(store, "w", 1e5)).toEqual([]);
|
package/dist/testkit.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { expect, it } from \"vitest\";\n\nimport { FAIL_STATE, type OutboxStore } from \"./port\";\n\nconst T0 = new Date(\"2026-10-26T12:00:00.000Z\");\nconst at = (seconds: number) => new Date(T0.valueOf() + seconds * 1000);\nconst RULES = { maxAttempts: 3, backoffSeconds: 30, backoffMaxSeconds: 100 };\nconst claimFor = (store: OutboxStore, owner: string, seconds: number, over: { topic?: string; limit?: number; lease?: number } = {}) =>\n store.claim({ owner, limit: over.limit ?? 10, leaseSeconds: over.lease ?? 60, maxAttempts: RULES.maxAttempts, now: at(seconds), ...(over.topic === undefined ? {} : { topic: over.topic }) });\nconst put = (store: OutboxStore, over: { id?: string; topic?: string; availableAt?: Date; payload?: unknown } = {}) => {\n const id = over.id ?? randomUUID();\n return store.append({ id, topic: over.topic ?? \"mail\", type: \"evt.mail.requested.v1\", payload: over.payload ?? { to: \"a@b.c\" }, createdAt: T0, availableAt: over.availableAt ?? T0 }).then(() => id);\n};\n\n/** What every `OutboxStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store. */\nexport function outboxStoreContract(name: string, make: () => OutboxStore | Promise<OutboxStore>) {\n it(`${name}: appends a message once, whatever the number of appends of its id`, async () => {\n const store = await make();\n const id = randomUUID();\n expect(await store.append({ id, topic: \"mail\", type: \"evt.mail.requested.v1\", payload: { n: 1 }, createdAt: T0, availableAt: T0 })).toBe(true);\n expect(await store.append({ id, topic: \"mail\", type: \"evt.mail.requested.v1\", payload: { n: 2 }, createdAt: T0, availableAt: T0 })).toBe(false);\n const [only, ...rest] = await claimFor(store, \"w1\", 0);\n expect(rest).toEqual([]);\n expect(only).toMatchObject({ id, topic: \"mail\", type: \"evt.mail.requested.v1\", payload: { n: 1 }, attempts: 1 });\n expect(only!.occurredAt.toISOString()).toBe(T0.toISOString());\n });\n\n it(`${name}: holds a claimed message for its lease and gives it to nobody else`, async () => {\n const store = await make();\n await put(store);\n expect(await claimFor(store, \"w1\", 0)).toHaveLength(1);\n expect(await claimFor(store, \"w2\", 30)).toEqual([]);\n expect(await claimFor(store, \"w2\", 59)).toEqual([]);\n });\n\n it(`${name}: delivers again after a crash: the lease expires without complete, and the message is claimed again`, async () => {\n const store = await make();\n const id = await put(store);\n expect((await claimFor(store, \"w1\", 0))[0]).toMatchObject({ id, attempts: 1 });\n // w1 delivered, then died before `complete`\n expect((await claimFor(store, \"w2\", 60))[0]).toMatchObject({ id, attempts: 2 });\n expect(await store.complete(id, \"w1\", at(61))).toBe(false); // the dead worker's late answer is refused\n expect(await store.complete(id, \"w2\", at(61))).toBe(true);\n });\n\n it(`${name}: completes once, for the holder of the lease only`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w1\", 0);\n expect(await store.complete(id, \"intruder\", at(1))).toBe(false);\n expect(await store.complete(id, \"w1\", at(1))).toBe(true);\n expect(await store.complete(id, \"w1\", at(2))).toBe(false);\n expect(await claimFor(store, \"w1\", 1000)).toEqual([]);\n });\n\n it(`${name}: backs off after a failure, doubling and capped, then goes dead after the last attempt`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w\", 0);\n expect(await store.fail(id, \"w\", \"boom\", { now: at(1), ...RULES })).toEqual({ state: \"retry\", availableAt: at(31) }); // 30 s\n expect(await claimFor(store, \"w\", 30)).toEqual([]);\n expect((await claimFor(store, \"w\", 31))[0]).toMatchObject({ id, attempts: 2 });\n expect(await store.fail(id, \"w\", \"boom\", { now: at(40), ...RULES })).toEqual({ state: \"retry\", availableAt: at(100) }); // 60 s\n expect((await claimFor(store, \"w\", 100))[0]).toMatchObject({ id, attempts: 3 });\n expect(await store.fail(id, \"w\", \"boom\", { now: at(110), ...RULES })).toEqual({ state: \"dead\" });\n expect(await claimFor(store, \"w\", 100_000)).toEqual([]);\n expect(await store.stats(at(200))).toMatchObject({ dead: 1, pending: 0, leased: 0 });\n });\n\n it(`${name}: caps the back-off`, async () => {\n const store = await make();\n const id = await put(store);\n const rules = { maxAttempts: 10, backoffSeconds: 30, backoffMaxSeconds: 100 };\n let clock = 0;\n const waits: number[] = [];\n for (let attempt = 1; attempt <= 4; attempt += 1) {\n const [m] = await store.claim({ owner: \"w\", limit: 1, leaseSeconds: 60, maxAttempts: 10, now: at(clock) });\n expect(m?.id).toBe(id);\n const outcome = await store.fail(id, \"w\", \"boom\", { now: at(clock), ...rules });\n if (outcome.state !== FAIL_STATE.retry) throw new Error(\"expected a retry\");\n waits.push((outcome.availableAt.valueOf() - at(clock).valueOf()) / 1000);\n clock = (outcome.availableAt.valueOf() - T0.valueOf()) / 1000;\n }\n expect(waits).toEqual([30, 60, 100, 100]);\n });\n\n it(`${name}: answers « lost » to a failure from a worker that no longer holds the lease`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w1\", 0);\n await claimFor(store, \"w2\", 60);\n expect(await store.fail(id, \"w1\", \"late\", { now: at(61), ...RULES })).toEqual({ state: \"lost\" });\n });\n\n it(`${name}: marks dead a message whose last allowed attempt's lease expired with no answer`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w\", 0);\n await claimFor(store, \"w\", 60);\n await claimFor(store, \"w\", 120); // the 3rd and last attempt; the worker dies\n expect(await claimFor(store, \"w\", 180)).toEqual([]);\n expect(await store.stats(at(181))).toMatchObject({ dead: 1, pending: 0 });\n expect(await store.retryDead(id, at(190))).toBe(true);\n expect((await claimFor(store, \"w\", 190))[0]).toMatchObject({ id, attempts: 1 });\n });\n\n it(`${name}: waits for the scheduled instant, filters by topic and honours the limit`, async () => {\n const store = await make();\n await put(store, { topic: \"later\", availableAt: at(500) });\n await put(store, { topic: \"mail\" });\n await put(store, { topic: \"mail\" });\n await put(store, { topic: \"other\" });\n expect(await claimFor(store, \"w\", 0, { topic: \"later\" })).toEqual([]);\n expect(await claimFor(store, \"w\", 0, { topic: \"mail\", limit: 1 })).toHaveLength(1);\n expect(await claimFor(store, \"w\", 0, { topic: \"mail\" })).toHaveLength(1);\n expect((await claimFor(store, \"w\", 500, { topic: \"later\" }))).toHaveLength(1);\n expect(await claimFor(store, \"w\", 0, { topic: \"other\" })).toHaveLength(1);\n });\n\n it(`${name}: purges what is delivered or dead and older than the cut-off, and keeps the rest`, async () => {\n const store = await make();\n await put(store);\n await put(store); // one is delivered, the other stays pending\n const [first] = await claimFor(store, \"w\", 0, { limit: 1 });\n await store.complete(first!.id, \"w\", at(10));\n expect(await store.purgeBefore(at(5))).toBe(0);\n expect(await store.purgeBefore(at(20))).toBe(1);\n expect(await store.stats(at(30))).toMatchObject({ delivered: 0, pending: 1 });\n });\n\n it(`${name}: never gives one message to two workers claiming at once`, async () => {\n const store = await make();\n for (let i = 0; i < 40; i += 1) await put(store);\n const claimers = Array.from({ length: 5 }, (_, i) => async () => {\n const mine: string[] = [];\n for (;;) {\n const batch = await claimFor(store, `w${i}`, 0, { limit: 4, lease: 600 });\n if (batch.length === 0) return mine;\n mine.push(...batch.map((m) => m.id));\n }\n });\n const taken = (await Promise.all(claimers.map((run) => run()))).flat();\n expect(taken).toHaveLength(40);\n expect(new Set(taken).size).toBe(40);\n });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\n\nimport { expect, it } from \"vitest\";\n\nimport { FAIL_STATE, type OutboxStore } from \"./port\";\n\nconst T0 = new Date(\"2026-10-26T12:00:00.000Z\");\nconst at = (seconds: number) => new Date(T0.valueOf() + seconds * 1000);\nconst RULES = { maxAttempts: 3, backoffSeconds: 30, backoffMaxSeconds: 100 };\nconst claimFor = (\n store: OutboxStore,\n owner: string,\n seconds: number,\n over: { topic?: string; limit?: number; lease?: number } = {},\n) =>\n store.claim({\n owner,\n limit: over.limit ?? 10,\n leaseSeconds: over.lease ?? 60,\n maxAttempts: RULES.maxAttempts,\n now: at(seconds),\n ...(over.topic === undefined ? {} : { topic: over.topic }),\n });\nconst put = (store: OutboxStore, over: { id?: string; topic?: string; availableAt?: Date; payload?: unknown } = {}) => {\n const id = over.id ?? randomUUID();\n return store\n .append({\n id,\n topic: over.topic ?? \"mail\",\n type: \"evt.mail.requested.v1\",\n payload: over.payload ?? { to: \"a@b.c\" },\n createdAt: T0,\n availableAt: over.availableAt ?? T0,\n })\n .then(() => id);\n};\n\n/** What every `OutboxStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store. */\nexport function outboxStoreContract(name: string, make: () => OutboxStore | Promise<OutboxStore>) {\n it(`${name}: appends a message once, whatever the number of appends of its id`, async () => {\n const store = await make();\n const id = randomUUID();\n expect(\n await store.append({\n id,\n topic: \"mail\",\n type: \"evt.mail.requested.v1\",\n payload: { n: 1 },\n createdAt: T0,\n availableAt: T0,\n }),\n ).toBe(true);\n expect(\n await store.append({\n id,\n topic: \"mail\",\n type: \"evt.mail.requested.v1\",\n payload: { n: 2 },\n createdAt: T0,\n availableAt: T0,\n }),\n ).toBe(false);\n const [only, ...rest] = await claimFor(store, \"w1\", 0);\n expect(rest).toEqual([]);\n expect(only).toMatchObject({ id, topic: \"mail\", type: \"evt.mail.requested.v1\", payload: { n: 1 }, attempts: 1 });\n expect(only!.occurredAt.toISOString()).toBe(T0.toISOString());\n });\n\n it(`${name}: holds a claimed message for its lease and gives it to nobody else`, async () => {\n const store = await make();\n await put(store);\n expect(await claimFor(store, \"w1\", 0)).toHaveLength(1);\n expect(await claimFor(store, \"w2\", 30)).toEqual([]);\n expect(await claimFor(store, \"w2\", 59)).toEqual([]);\n });\n\n it(`${name}: delivers again after a crash: the lease expires without complete, and the message is claimed again`, async () => {\n const store = await make();\n const id = await put(store);\n expect((await claimFor(store, \"w1\", 0))[0]).toMatchObject({ id, attempts: 1 });\n // w1 delivered, then died before `complete`\n expect((await claimFor(store, \"w2\", 60))[0]).toMatchObject({ id, attempts: 2 });\n expect(await store.complete(id, \"w1\", at(61))).toBe(false); // the dead worker's late answer is refused\n expect(await store.complete(id, \"w2\", at(61))).toBe(true);\n });\n\n it(`${name}: completes once, for the holder of the lease only`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w1\", 0);\n expect(await store.complete(id, \"intruder\", at(1))).toBe(false);\n expect(await store.complete(id, \"w1\", at(1))).toBe(true);\n expect(await store.complete(id, \"w1\", at(2))).toBe(false);\n expect(await claimFor(store, \"w1\", 1000)).toEqual([]);\n });\n\n it(`${name}: backs off after a failure, doubling and capped, then goes dead after the last attempt`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w\", 0);\n expect(await store.fail(id, \"w\", \"boom\", { now: at(1), ...RULES })).toEqual({\n state: \"retry\",\n availableAt: at(31),\n }); // 30 s\n expect(await claimFor(store, \"w\", 30)).toEqual([]);\n expect((await claimFor(store, \"w\", 31))[0]).toMatchObject({ id, attempts: 2 });\n expect(await store.fail(id, \"w\", \"boom\", { now: at(40), ...RULES })).toEqual({\n state: \"retry\",\n availableAt: at(100),\n }); // 60 s\n expect((await claimFor(store, \"w\", 100))[0]).toMatchObject({ id, attempts: 3 });\n expect(await store.fail(id, \"w\", \"boom\", { now: at(110), ...RULES })).toEqual({ state: \"dead\" });\n expect(await claimFor(store, \"w\", 100_000)).toEqual([]);\n expect(await store.stats(at(200))).toMatchObject({ dead: 1, pending: 0, leased: 0 });\n });\n\n it(`${name}: caps the back-off`, async () => {\n const store = await make();\n const id = await put(store);\n const rules = { maxAttempts: 10, backoffSeconds: 30, backoffMaxSeconds: 100 };\n let clock = 0;\n const waits: number[] = [];\n for (let attempt = 1; attempt <= 4; attempt += 1) {\n const [m] = await store.claim({ owner: \"w\", limit: 1, leaseSeconds: 60, maxAttempts: 10, now: at(clock) });\n expect(m?.id).toBe(id);\n const outcome = await store.fail(id, \"w\", \"boom\", { now: at(clock), ...rules });\n if (outcome.state !== FAIL_STATE.retry) throw new Error(\"expected a retry\");\n waits.push((outcome.availableAt.valueOf() - at(clock).valueOf()) / 1000);\n clock = (outcome.availableAt.valueOf() - T0.valueOf()) / 1000;\n }\n expect(waits).toEqual([30, 60, 100, 100]);\n });\n\n it(`${name}: answers « lost » to a failure from a worker that no longer holds the lease`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w1\", 0);\n await claimFor(store, \"w2\", 60);\n expect(await store.fail(id, \"w1\", \"late\", { now: at(61), ...RULES })).toEqual({ state: \"lost\" });\n });\n\n it(`${name}: marks dead a message whose last allowed attempt's lease expired with no answer`, async () => {\n const store = await make();\n const id = await put(store);\n await claimFor(store, \"w\", 0);\n await claimFor(store, \"w\", 60);\n await claimFor(store, \"w\", 120); // the 3rd and last attempt; the worker dies\n expect(await claimFor(store, \"w\", 180)).toEqual([]);\n expect(await store.stats(at(181))).toMatchObject({ dead: 1, pending: 0 });\n expect(await store.retryDead(id, at(190))).toBe(true);\n expect((await claimFor(store, \"w\", 190))[0]).toMatchObject({ id, attempts: 1 });\n });\n\n it(`${name}: waits for the scheduled instant, filters by topic and honours the limit`, async () => {\n const store = await make();\n await put(store, { topic: \"later\", availableAt: at(500) });\n await put(store, { topic: \"mail\" });\n await put(store, { topic: \"mail\" });\n await put(store, { topic: \"other\" });\n expect(await claimFor(store, \"w\", 0, { topic: \"later\" })).toEqual([]);\n expect(await claimFor(store, \"w\", 0, { topic: \"mail\", limit: 1 })).toHaveLength(1);\n expect(await claimFor(store, \"w\", 0, { topic: \"mail\" })).toHaveLength(1);\n expect(await claimFor(store, \"w\", 500, { topic: \"later\" })).toHaveLength(1);\n expect(await claimFor(store, \"w\", 0, { topic: \"other\" })).toHaveLength(1);\n });\n\n it(`${name}: purges what is delivered or dead and older than the cut-off, and keeps the rest`, async () => {\n const store = await make();\n await put(store);\n await put(store); // one is delivered, the other stays pending\n const [first] = await claimFor(store, \"w\", 0, { limit: 1 });\n await store.complete(first!.id, \"w\", at(10));\n expect(await store.purgeBefore(at(5))).toBe(0);\n expect(await store.purgeBefore(at(20))).toBe(1);\n expect(await store.stats(at(30))).toMatchObject({ delivered: 0, pending: 1 });\n });\n\n it(`${name}: never gives one message to two workers claiming at once`, async () => {\n const store = await make();\n for (let i = 0; i < 40; i += 1) await put(store);\n const claimers = Array.from({ length: 5 }, (_, i) => async () => {\n const mine: string[] = [];\n for (;;) {\n const batch = await claimFor(store, `w${i}`, 0, { limit: 4, lease: 600 });\n if (batch.length === 0) return mine;\n mine.push(...batch.map((m) => m.id));\n }\n });\n const taken = (await Promise.all(claimers.map((run) => run()))).flat();\n expect(taken).toHaveLength(40);\n expect(new Set(taken).size).toBe(40);\n });\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/outbox",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The transactional outbox of every Krizaka product: an event written in the caller's transaction, delivered after the commit at least once by leased workers, with doubling retries, dead letters and an idempotent-consumer helper. No broker, no database client, no wall clock.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
"typescript": "^5.9.3",
|
|
56
56
|
"vitest": "^4.1.11",
|
|
57
57
|
"zod": "^4.6.7",
|
|
58
|
-
"@krizaka/config": "^0.
|
|
58
|
+
"@krizaka/config": "^0.20.0"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@krizaka/db": "^0.3.
|
|
62
|
-
"@krizaka/settings": "^0.
|
|
63
|
-
"@krizaka/time": "^0.
|
|
61
|
+
"@krizaka/db": "^0.3.1",
|
|
62
|
+
"@krizaka/settings": "^0.6.0",
|
|
63
|
+
"@krizaka/time": "^0.2.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"vitest": ">=4",
|
package/dist/chunk-AD76AB44.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
OUTBOX_CODE,
|
|
3
|
-
OutboxError
|
|
4
|
-
} from "./chunk-7WIJNAGK.js";
|
|
5
|
-
|
|
6
|
-
// src/rules.ts
|
|
7
|
-
var OUTBOX_DEFAULTS = { batchSize: 50, leaseSeconds: 60, maxAttempts: 8, backoffSeconds: 30, backoffMaxSeconds: 3600, retentionDays: 7 };
|
|
8
|
-
var OUTBOX_BOUNDS = {
|
|
9
|
-
batchSize: [1, 500],
|
|
10
|
-
leaseSeconds: [10, 3600],
|
|
11
|
-
maxAttempts: [1, 50],
|
|
12
|
-
backoffSeconds: [1, 3600],
|
|
13
|
-
backoffMaxSeconds: [1, 86400],
|
|
14
|
-
retentionDays: [1, 365]
|
|
15
|
-
};
|
|
16
|
-
var TOPIC = /^[a-z][a-z0-9_.:-]{0,59}$/;
|
|
17
|
-
var TYPE = /^[a-z][a-z0-9_.-]{0,99}$/;
|
|
18
|
-
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
-
function checkMessage(input) {
|
|
20
|
-
if (!TOPIC.test(input.topic)) throw new OutboxError(OUTBOX_CODE.badTopic, `topic \xAB ${input.topic} \xBB: lower-case letters, digits and _ . : -, starting with a letter, at most 60 characters`);
|
|
21
|
-
if (!TYPE.test(input.type)) throw new OutboxError(OUTBOX_CODE.badType, `type \xAB ${input.type} \xBB: lower-case letters, digits and _ . -, starting with a letter, at most 100 characters (evt.credit.granted.v1)`);
|
|
22
|
-
if (input.id !== void 0 && !UUID.test(input.id)) throw new OutboxError(OUTBOX_CODE.badId, `id \xAB ${input.id} \xBB is not a UUID`);
|
|
23
|
-
if (input.payload === void 0 || JSON.stringify(input.payload) === void 0) throw new OutboxError(OUTBOX_CODE.badPayload, "the payload must be JSON-serialisable (and not undefined)");
|
|
24
|
-
}
|
|
25
|
-
function checkSettings(settings) {
|
|
26
|
-
for (const key of Object.keys(OUTBOX_BOUNDS)) {
|
|
27
|
-
const [min, max] = OUTBOX_BOUNDS[key];
|
|
28
|
-
const value = settings[key];
|
|
29
|
-
if (!Number.isInteger(value) || value < min || value > max) throw new OutboxError(OUTBOX_CODE.badSettings, `outbox.${key} must be a whole number from ${min} to ${max}, got ${value}`);
|
|
30
|
-
}
|
|
31
|
-
return settings;
|
|
32
|
-
}
|
|
33
|
-
function backoffSeconds(attempts, settings) {
|
|
34
|
-
return Math.min(settings.backoffMaxSeconds, settings.backoffSeconds * 2 ** Math.max(0, attempts - 1));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export {
|
|
38
|
-
OUTBOX_DEFAULTS,
|
|
39
|
-
OUTBOX_BOUNDS,
|
|
40
|
-
checkMessage,
|
|
41
|
-
checkSettings,
|
|
42
|
-
backoffSeconds
|
|
43
|
-
};
|
|
44
|
-
//# sourceMappingURL=chunk-AD76AB44.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/rules.ts"],"sourcesContent":["import { OUTBOX_CODE, OutboxError, type OutboxSettings } from \"./port\";\n\n/**\n * The defaults of the settings, and their bounds. The settings fragment (`@krizaka/outbox/settings`) is built from these, so the\n * admin sees the same numbers the code falls back to.\n */\n// @fixed: the defaults and bounds of the admin's knobs; the admin chooses within them\nexport const OUTBOX_DEFAULTS: OutboxSettings = { batchSize: 50, leaseSeconds: 60, maxAttempts: 8, backoffSeconds: 30, backoffMaxSeconds: 3600, retentionDays: 7 };\n\n/** The bounds of each setting: [min, max]. */\n// @fixed: what an admin may set\nexport const OUTBOX_BOUNDS: Readonly<Record<keyof OutboxSettings, readonly [number, number]>> = {\n batchSize: [1, 500],\n leaseSeconds: [10, 3600],\n maxAttempts: [1, 50],\n backoffSeconds: [1, 3600],\n backoffMaxSeconds: [1, 86_400],\n retentionDays: [1, 365],\n};\n\nconst TOPIC = /^[a-z][a-z0-9_.:-]{0,59}$/;\nconst TYPE = /^[a-z][a-z0-9_.-]{0,99}$/;\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** @throws OutboxError for a topic, a type or an id that is not well formed, or a payload that is not JSON */\nexport function checkMessage(input: { topic: string; type: string; id?: string; payload: unknown }): void {\n if (!TOPIC.test(input.topic)) throw new OutboxError(OUTBOX_CODE.badTopic, `topic « ${input.topic} »: lower-case letters, digits and _ . : -, starting with a letter, at most 60 characters`);\n if (!TYPE.test(input.type)) throw new OutboxError(OUTBOX_CODE.badType, `type « ${input.type} »: lower-case letters, digits and _ . -, starting with a letter, at most 100 characters (evt.credit.granted.v1)`);\n if (input.id !== undefined && !UUID.test(input.id)) throw new OutboxError(OUTBOX_CODE.badId, `id « ${input.id} » is not a UUID`);\n if (input.payload === undefined || JSON.stringify(input.payload) === undefined) throw new OutboxError(OUTBOX_CODE.badPayload, \"the payload must be JSON-serialisable (and not undefined)\");\n}\n\n/** @throws OutboxError when a setting is not a whole number within its bounds */\nexport function checkSettings(settings: OutboxSettings): OutboxSettings {\n for (const key of Object.keys(OUTBOX_BOUNDS) as (keyof OutboxSettings)[]) {\n const [min, max] = OUTBOX_BOUNDS[key];\n const value = settings[key];\n if (!Number.isInteger(value) || value < min || value > max) throw new OutboxError(OUTBOX_CODE.badSettings, `outbox.${key} must be a whole number from ${min} to ${max}, got ${value}`);\n }\n return settings;\n}\n\n/**\n * The seconds a message waits after its `attempts`-th delivery failed: the base for the first, doubling with each, capped.\n * @param attempts deliveries started so far (1 for the first failure)\n */\nexport function backoffSeconds(attempts: number, settings: Pick<OutboxSettings, \"backoffSeconds\" | \"backoffMaxSeconds\">): number {\n return Math.min(settings.backoffMaxSeconds, settings.backoffSeconds * 2 ** Math.max(0, attempts - 1));\n}\n"],"mappings":";;;;;;AAOO,IAAM,kBAAkC,EAAE,WAAW,IAAI,cAAc,IAAI,aAAa,GAAG,gBAAgB,IAAI,mBAAmB,MAAM,eAAe,EAAE;AAIzJ,IAAM,gBAAmF;AAAA,EAC9F,WAAW,CAAC,GAAG,GAAG;AAAA,EAClB,cAAc,CAAC,IAAI,IAAI;AAAA,EACvB,aAAa,CAAC,GAAG,EAAE;AAAA,EACnB,gBAAgB,CAAC,GAAG,IAAI;AAAA,EACxB,mBAAmB,CAAC,GAAG,KAAM;AAAA,EAC7B,eAAe,CAAC,GAAG,GAAG;AACxB;AAEA,IAAM,QAAQ;AACd,IAAM,OAAO;AACb,IAAM,OAAO;AAGN,SAAS,aAAa,OAA6E;AACxG,MAAI,CAAC,MAAM,KAAK,MAAM,KAAK,EAAG,OAAM,IAAI,YAAY,YAAY,UAAU,cAAW,MAAM,KAAK,8FAA2F;AAC3L,MAAI,CAAC,KAAK,KAAK,MAAM,IAAI,EAAG,OAAM,IAAI,YAAY,YAAY,SAAS,aAAU,MAAM,IAAI,qHAAkH;AAC7M,MAAI,MAAM,OAAO,UAAa,CAAC,KAAK,KAAK,MAAM,EAAE,EAAG,OAAM,IAAI,YAAY,YAAY,OAAO,WAAQ,MAAM,EAAE,qBAAkB;AAC/H,MAAI,MAAM,YAAY,UAAa,KAAK,UAAU,MAAM,OAAO,MAAM,OAAW,OAAM,IAAI,YAAY,YAAY,YAAY,2DAA2D;AAC3L;AAGO,SAAS,cAAc,UAA0C;AACtE,aAAW,OAAO,OAAO,KAAK,aAAa,GAA+B;AACxE,UAAM,CAAC,KAAK,GAAG,IAAI,cAAc,GAAG;AACpC,UAAM,QAAQ,SAAS,GAAG;AAC1B,QAAI,CAAC,OAAO,UAAU,KAAK,KAAK,QAAQ,OAAO,QAAQ,IAAK,OAAM,IAAI,YAAY,YAAY,aAAa,UAAU,GAAG,gCAAgC,GAAG,OAAO,GAAG,SAAS,KAAK,EAAE;AAAA,EACvL;AACA,SAAO;AACT;AAMO,SAAS,eAAe,UAAkB,UAAgF;AAC/H,SAAO,KAAK,IAAI,SAAS,mBAAmB,SAAS,iBAAiB,KAAK,KAAK,IAAI,GAAG,WAAW,CAAC,CAAC;AACtG;","names":[]}
|