@kronos-ts/knex 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { knexTransactionManager, type KnexInstanceLike, type KnexTransaction, } from "./knex-transaction-manager.js";
2
2
  export { knexTokenStore, type KnexQueryable, } from "./knex-token-store.js";
3
+ export { knexDeadLetterQueue, type KnexDeadLetterQueueConfig, } from "./knex-dead-letter-queue.js";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,+BAA+B,CAAA;AAEtC,OAAO,EACL,cAAc,EACd,KAAK,aAAa,GACnB,MAAM,uBAAuB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,KAAK,gBAAgB,EACrB,KAAK,eAAe,GACrB,MAAM,+BAA+B,CAAA;AAEtC,OAAO,EACL,cAAc,EACd,KAAK,aAAa,GACnB,MAAM,uBAAuB,CAAA;AAE9B,OAAO,EACL,mBAAmB,EACnB,KAAK,yBAAyB,GAC/B,MAAM,6BAA6B,CAAA"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { knexTransactionManager, } from "./knex-transaction-manager.js";
2
2
  export { knexTokenStore, } from "./knex-token-store.js";
3
+ export { knexDeadLetterQueue, } from "./knex-dead-letter-queue.js";
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,GAGvB,MAAM,+BAA+B,CAAA;AAEtC,OAAO,EACL,cAAc,GAEf,MAAM,uBAAuB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,GAGvB,MAAM,+BAA+B,CAAA;AAEtC,OAAO,EACL,cAAc,GAEf,MAAM,uBAAuB,CAAA;AAE9B,OAAO,EACL,mBAAmB,GAEpB,MAAM,6BAA6B,CAAA"}
@@ -0,0 +1,57 @@
1
+ import type { SequencedDeadLetterQueue } from "@kronos-ts/messaging";
2
+ import type { KnexQueryable } from "./knex-token-store.js";
3
+ /**
4
+ * Persistent {@link SequencedDeadLetterQueue} backed by Knex — mirrors the
5
+ * Drizzle reference implementation, translated to Knex's query builder style.
6
+ *
7
+ * Like {@link knexTokenStore} it reads the active transaction via
8
+ * `getActiveTransaction()`, so enqueue/evict/requeue commit in the **same
9
+ * transaction as the token update** — a crash cannot advance the processor's
10
+ * token while losing the parked letter.
11
+ *
12
+ * The table is shared across processors and partitioned by `processingGroup`.
13
+ * Per-sequence FIFO order is held by a monotonic `sequence_index`; a
14
+ * `processing_started` lease column makes `process()` safe across multiple
15
+ * nodes (Axon parity).
16
+ *
17
+ * Expected table (`kronos_dead_letters`), columns:
18
+ * - `dead_letter_id` (PK) — persistent row id (varchar)
19
+ * - `processing_group` — the processor name partition (varchar, not null)
20
+ * - `sequence_identifier` — the sequence this letter belongs to (varchar, not null)
21
+ * - `sequence_index` — monotonic per-sequence FIFO index (integer, not null)
22
+ * - `message` — JSON-serialized EventMessage (text, not null)
23
+ * - `cause_type` — error name / type (varchar, nullable)
24
+ * - `cause_message` — error message (text, nullable)
25
+ * - `diagnostics` — JSON-serialized diagnostics map (text, not null)
26
+ * - `enqueued_at` — epoch-ms as string (varchar, not null)
27
+ * - `last_touched` — epoch-ms as string (varchar, not null)
28
+ * - `processing_started` — epoch-ms lease as string (varchar, nullable)
29
+ *
30
+ * A composite index on `(processing_group, sequence_identifier, sequence_index)`
31
+ * keeps per-sequence reads ordered.
32
+ */
33
+ export interface KnexDeadLetterQueueConfig {
34
+ /** Processing group (the processor name) this queue serves. */
35
+ processingGroup: string;
36
+ /** Table name. Default: "kronos_dead_letters". */
37
+ tableName?: string;
38
+ /** Maximum number of sequences. Default: 1024 (Axon parity). */
39
+ maxSequences?: number;
40
+ /** Maximum letters per sequence. Default: 1024 (Axon parity). */
41
+ maxSequenceSize?: number;
42
+ /** Lease duration for in-flight processing, ms. Default: 30000 (Axon parity). */
43
+ claimDurationMs?: number;
44
+ }
45
+ /**
46
+ * Creates a {@link SequencedDeadLetterQueue} backed by Knex.
47
+ *
48
+ * Participates in the active transaction via `getActiveTransaction()`.
49
+ *
50
+ * ```typescript
51
+ * import { knexDeadLetterQueue } from "@kronos-ts/knex"
52
+ *
53
+ * const dlq = knexDeadLetterQueue(knex, { processingGroup: "my-processor" })
54
+ * ```
55
+ */
56
+ export declare function knexDeadLetterQueue(knex: KnexQueryable, config: KnexDeadLetterQueueConfig): SequencedDeadLetterQueue;
57
+ //# sourceMappingURL=knex-dead-letter-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"knex-dead-letter-queue.d.ts","sourceRoot":"","sources":["../src/knex-dead-letter-queue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA+B,wBAAwB,EAAE,MAAM,sBAAsB,CAAA;AAGjG,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,yBAAyB;IACxC,+DAA+D;IAC/D,eAAe,EAAE,MAAM,CAAA;IACvB,kDAAkD;IAClD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAYD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,aAAa,EACnB,MAAM,EAAE,yBAAyB,GAChC,wBAAwB,CA6M1B"}
@@ -0,0 +1,214 @@
1
+ import { getActiveTransaction, DeadLetterQueueOverflowError } from "@kronos-ts/messaging";
2
+ /** Reserved diagnostics key carrying the persistent row id across read → evict/requeue. */
3
+ const DL_ID = "__dlqId";
4
+ let idCounter = 0;
5
+ function newId(group) {
6
+ // Unique within the table: time + per-process counter + group.
7
+ idCounter += 1;
8
+ return `${group}:${Date.now()}:${idCounter}`;
9
+ }
10
+ /**
11
+ * Creates a {@link SequencedDeadLetterQueue} backed by Knex.
12
+ *
13
+ * Participates in the active transaction via `getActiveTransaction()`.
14
+ *
15
+ * ```typescript
16
+ * import { knexDeadLetterQueue } from "@kronos-ts/knex"
17
+ *
18
+ * const dlq = knexDeadLetterQueue(knex, { processingGroup: "my-processor" })
19
+ * ```
20
+ */
21
+ export function knexDeadLetterQueue(knex, config) {
22
+ const { processingGroup } = config;
23
+ const table = config.tableName ?? "kronos_dead_letters";
24
+ const maxSequences = config.maxSequences ?? 1024;
25
+ const maxSequenceSize = config.maxSequenceSize ?? 1024;
26
+ const claimDurationMs = config.claimDurationMs ?? 30000;
27
+ function getKnex() {
28
+ return getActiveTransaction() ?? knex;
29
+ }
30
+ function rowToLetter(row) {
31
+ const cause = new Error(row.cause_message ?? "");
32
+ if (row.cause_type)
33
+ cause.name = row.cause_type;
34
+ return {
35
+ message: JSON.parse(row.message),
36
+ cause,
37
+ enqueuedAt: Number(row.enqueued_at),
38
+ lastTouched: Number(row.last_touched),
39
+ diagnostics: { ...JSON.parse(row.diagnostics), [DL_ID]: row.dead_letter_id },
40
+ sequenceIdentifier: row.sequence_identifier,
41
+ };
42
+ }
43
+ function letterToRow(letter, sequenceIndex, deadLetterId) {
44
+ const { [DL_ID]: _omit, ...diagnostics } = letter.diagnostics;
45
+ return {
46
+ dead_letter_id: deadLetterId,
47
+ processing_group: processingGroup,
48
+ sequence_identifier: letter.sequenceIdentifier,
49
+ sequence_index: sequenceIndex,
50
+ message: JSON.stringify(letter.message),
51
+ cause_type: letter.cause.name,
52
+ cause_message: letter.cause.message,
53
+ diagnostics: JSON.stringify(diagnostics),
54
+ enqueued_at: String(letter.enqueuedAt),
55
+ last_touched: String(letter.lastTouched),
56
+ processing_started: null,
57
+ };
58
+ }
59
+ async function sequenceRows(k, seqId) {
60
+ return k(table)
61
+ .where({ processing_group: processingGroup, sequence_identifier: seqId })
62
+ .orderBy("sequence_index", "asc");
63
+ }
64
+ async function distinctSequences(k) {
65
+ const rows = await k(table)
66
+ .where({ processing_group: processingGroup })
67
+ .distinct("sequence_identifier");
68
+ return rows.map((r) => r.sequence_identifier);
69
+ }
70
+ return {
71
+ async enqueue(letter) {
72
+ const k = getKnex();
73
+ const existing = await sequenceRows(k, letter.sequenceIdentifier);
74
+ if (existing.length === 0) {
75
+ if ((await distinctSequences(k)).length >= maxSequences) {
76
+ throw new DeadLetterQueueOverflowError(`max sequences ${maxSequences} reached`);
77
+ }
78
+ }
79
+ else if (existing.length >= maxSequenceSize) {
80
+ throw new DeadLetterQueueOverflowError(`sequence "${letter.sequenceIdentifier}" has reached max size ${maxSequenceSize}`);
81
+ }
82
+ const idx = existing.length === 0 ? 0 : existing[existing.length - 1].sequence_index + 1;
83
+ await k(table).insert(letterToRow(letter, idx, newId(processingGroup)));
84
+ },
85
+ async enqueueIfPresent(sequenceIdentifier, letterSupplier) {
86
+ const k = getKnex();
87
+ const existing = await sequenceRows(k, sequenceIdentifier);
88
+ if (existing.length === 0)
89
+ return false;
90
+ if (existing.length >= maxSequenceSize) {
91
+ throw new DeadLetterQueueOverflowError(`sequence "${sequenceIdentifier}" has reached max size ${maxSequenceSize}`);
92
+ }
93
+ const idx = existing[existing.length - 1].sequence_index + 1;
94
+ await k(table).insert(letterToRow(letterSupplier(), idx, newId(processingGroup)));
95
+ return true;
96
+ },
97
+ async evict(_sequenceIdentifier, letter) {
98
+ const k = getKnex();
99
+ const id = letter.diagnostics[DL_ID];
100
+ if (typeof id !== "string")
101
+ return;
102
+ await k(table)
103
+ .where({ processing_group: processingGroup, dead_letter_id: id })
104
+ .delete();
105
+ },
106
+ async requeue(letter, update) {
107
+ const k = getKnex();
108
+ const id = letter.diagnostics[DL_ID];
109
+ if (typeof id !== "string")
110
+ return;
111
+ const { [DL_ID]: _omit, ...baseDiag } = letter.diagnostics;
112
+ const cause = update?.cause ?? letter.cause;
113
+ const diagnostics = update?.diagnostics ? { ...baseDiag, ...update.diagnostics } : baseDiag;
114
+ await k(table)
115
+ .where({ processing_group: processingGroup, dead_letter_id: id })
116
+ .update({
117
+ cause_type: cause.name,
118
+ cause_message: cause.message,
119
+ diagnostics: JSON.stringify(diagnostics),
120
+ last_touched: String(Date.now()),
121
+ });
122
+ },
123
+ async contains(sequenceIdentifier) {
124
+ const k = getKnex();
125
+ const row = await k(table)
126
+ .where({ processing_group: processingGroup, sequence_identifier: sequenceIdentifier })
127
+ .first();
128
+ return row != null;
129
+ },
130
+ async deadLetterSequence(sequenceIdentifier) {
131
+ const k = getKnex();
132
+ return (await sequenceRows(k, sequenceIdentifier)).map(rowToLetter);
133
+ },
134
+ async sequenceIdentifiers() {
135
+ return distinctSequences(getKnex());
136
+ },
137
+ async process(sequenceFilter, processingTask) {
138
+ const k = getKnex();
139
+ const candidates = (await distinctSequences(k)).filter(sequenceFilter);
140
+ if (candidates.length === 0)
141
+ return false;
142
+ // Pick the oldest sequence by its head letter's lastTouched, skipping
143
+ // sequences under an unexpired processing lease (multi-node safety).
144
+ const cutoff = Date.now() - claimDurationMs;
145
+ let chosen;
146
+ let oldest = Infinity;
147
+ for (const seqId of candidates) {
148
+ const rows = await sequenceRows(k, seqId);
149
+ if (rows.length === 0)
150
+ continue;
151
+ const head = rows[0];
152
+ const leased = head.processing_started != null && Number(head.processing_started) > cutoff;
153
+ if (leased)
154
+ continue;
155
+ if (Number(head.last_touched) < oldest) {
156
+ oldest = Number(head.last_touched);
157
+ chosen = seqId;
158
+ }
159
+ }
160
+ if (!chosen)
161
+ return false;
162
+ // Claim the sequence head's lease for the duration of this pass.
163
+ const headRows = await sequenceRows(k, chosen);
164
+ await k(table)
165
+ .where({ processing_group: processingGroup, dead_letter_id: headRows[0].dead_letter_id })
166
+ .update({ processing_started: String(Date.now()) });
167
+ try {
168
+ for (const row of headRows) {
169
+ const letter = rowToLetter(row);
170
+ const decision = await processingTask(letter);
171
+ if (decision.shouldEnqueue) {
172
+ await this.requeue(letter, { cause: decision.cause, diagnostics: decision.diagnostics });
173
+ return true;
174
+ }
175
+ await this.evict(chosen, letter);
176
+ }
177
+ return true;
178
+ }
179
+ finally {
180
+ // Release any lease still set on a surviving head.
181
+ const remaining = await sequenceRows(k, chosen);
182
+ if (remaining.length > 0 && remaining[0].processing_started != null) {
183
+ await k(table)
184
+ .where({ processing_group: processingGroup, dead_letter_id: remaining[0].dead_letter_id })
185
+ .update({ processing_started: null });
186
+ }
187
+ }
188
+ },
189
+ async size() {
190
+ const k = getKnex();
191
+ const rows = await k(table)
192
+ .where({ processing_group: processingGroup })
193
+ .select("dead_letter_id");
194
+ return rows.length;
195
+ },
196
+ async amountOfSequences() {
197
+ return (await distinctSequences(getKnex())).length;
198
+ },
199
+ async clear() {
200
+ const k = getKnex();
201
+ await k(table)
202
+ .where({ processing_group: processingGroup })
203
+ .delete();
204
+ },
205
+ async isFull(sequenceIdentifier) {
206
+ const k = getKnex();
207
+ const rows = await sequenceRows(k, sequenceIdentifier);
208
+ if (rows.length > 0)
209
+ return rows.length >= maxSequenceSize;
210
+ return (await distinctSequences(k)).length >= maxSequences;
211
+ },
212
+ };
213
+ }
214
+ //# sourceMappingURL=knex-dead-letter-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"knex-dead-letter-queue.js","sourceRoot":"","sources":["../src/knex-dead-letter-queue.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kronos-ts/knex",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Knex extension for Kronos.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@kronos-ts/common": "0.1.0",
52
- "@kronos-ts/messaging": "0.1.0"
51
+ "@kronos-ts/common": "0.1.1",
52
+ "@kronos-ts/messaging": "0.4.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "knex": ">=3.0.0"
package/src/index.ts CHANGED
@@ -8,3 +8,8 @@ export {
8
8
  knexTokenStore,
9
9
  type KnexQueryable,
10
10
  } from "./knex-token-store.js"
11
+
12
+ export {
13
+ knexDeadLetterQueue,
14
+ type KnexDeadLetterQueueConfig,
15
+ } from "./knex-dead-letter-queue.js"
@@ -0,0 +1,278 @@
1
+ import type { DeadLetter, EnqueueDecision, SequencedDeadLetterQueue } from "@kronos-ts/messaging"
2
+ import { getActiveTransaction, DeadLetterQueueOverflowError } from "@kronos-ts/messaging"
3
+ import type { KnexTransaction } from "./knex-transaction-manager.js"
4
+ import type { KnexQueryable } from "./knex-token-store.js"
5
+
6
+ /**
7
+ * Persistent {@link SequencedDeadLetterQueue} backed by Knex — mirrors the
8
+ * Drizzle reference implementation, translated to Knex's query builder style.
9
+ *
10
+ * Like {@link knexTokenStore} it reads the active transaction via
11
+ * `getActiveTransaction()`, so enqueue/evict/requeue commit in the **same
12
+ * transaction as the token update** — a crash cannot advance the processor's
13
+ * token while losing the parked letter.
14
+ *
15
+ * The table is shared across processors and partitioned by `processingGroup`.
16
+ * Per-sequence FIFO order is held by a monotonic `sequence_index`; a
17
+ * `processing_started` lease column makes `process()` safe across multiple
18
+ * nodes (Axon parity).
19
+ *
20
+ * Expected table (`kronos_dead_letters`), columns:
21
+ * - `dead_letter_id` (PK) — persistent row id (varchar)
22
+ * - `processing_group` — the processor name partition (varchar, not null)
23
+ * - `sequence_identifier` — the sequence this letter belongs to (varchar, not null)
24
+ * - `sequence_index` — monotonic per-sequence FIFO index (integer, not null)
25
+ * - `message` — JSON-serialized EventMessage (text, not null)
26
+ * - `cause_type` — error name / type (varchar, nullable)
27
+ * - `cause_message` — error message (text, nullable)
28
+ * - `diagnostics` — JSON-serialized diagnostics map (text, not null)
29
+ * - `enqueued_at` — epoch-ms as string (varchar, not null)
30
+ * - `last_touched` — epoch-ms as string (varchar, not null)
31
+ * - `processing_started` — epoch-ms lease as string (varchar, nullable)
32
+ *
33
+ * A composite index on `(processing_group, sequence_identifier, sequence_index)`
34
+ * keeps per-sequence reads ordered.
35
+ */
36
+ export interface KnexDeadLetterQueueConfig {
37
+ /** Processing group (the processor name) this queue serves. */
38
+ processingGroup: string
39
+ /** Table name. Default: "kronos_dead_letters". */
40
+ tableName?: string
41
+ /** Maximum number of sequences. Default: 1024 (Axon parity). */
42
+ maxSequences?: number
43
+ /** Maximum letters per sequence. Default: 1024 (Axon parity). */
44
+ maxSequenceSize?: number
45
+ /** Lease duration for in-flight processing, ms. Default: 30000 (Axon parity). */
46
+ claimDurationMs?: number
47
+ }
48
+
49
+ /** Reserved diagnostics key carrying the persistent row id across read → evict/requeue. */
50
+ const DL_ID = "__dlqId"
51
+
52
+ let idCounter = 0
53
+ function newId(group: string): string {
54
+ // Unique within the table: time + per-process counter + group.
55
+ idCounter += 1
56
+ return `${group}:${Date.now()}:${idCounter}`
57
+ }
58
+
59
+ /**
60
+ * Creates a {@link SequencedDeadLetterQueue} backed by Knex.
61
+ *
62
+ * Participates in the active transaction via `getActiveTransaction()`.
63
+ *
64
+ * ```typescript
65
+ * import { knexDeadLetterQueue } from "@kronos-ts/knex"
66
+ *
67
+ * const dlq = knexDeadLetterQueue(knex, { processingGroup: "my-processor" })
68
+ * ```
69
+ */
70
+ export function knexDeadLetterQueue(
71
+ knex: KnexQueryable,
72
+ config: KnexDeadLetterQueueConfig,
73
+ ): SequencedDeadLetterQueue {
74
+ const { processingGroup } = config
75
+ const table = config.tableName ?? "kronos_dead_letters"
76
+ const maxSequences = config.maxSequences ?? 1024
77
+ const maxSequenceSize = config.maxSequenceSize ?? 1024
78
+ const claimDurationMs = config.claimDurationMs ?? 30000
79
+
80
+ function getKnex(): KnexQueryable {
81
+ return getActiveTransaction<KnexTransaction>() ?? knex
82
+ }
83
+
84
+ function rowToLetter(row: any): DeadLetter {
85
+ const cause = new Error(row.cause_message ?? "")
86
+ if (row.cause_type) cause.name = row.cause_type
87
+ return {
88
+ message: JSON.parse(row.message),
89
+ cause,
90
+ enqueuedAt: Number(row.enqueued_at),
91
+ lastTouched: Number(row.last_touched),
92
+ diagnostics: { ...JSON.parse(row.diagnostics), [DL_ID]: row.dead_letter_id },
93
+ sequenceIdentifier: row.sequence_identifier,
94
+ }
95
+ }
96
+
97
+ function letterToRow(letter: DeadLetter, sequenceIndex: number, deadLetterId: string) {
98
+ const { [DL_ID]: _omit, ...diagnostics } = letter.diagnostics as Record<string, unknown>
99
+ return {
100
+ dead_letter_id: deadLetterId,
101
+ processing_group: processingGroup,
102
+ sequence_identifier: letter.sequenceIdentifier,
103
+ sequence_index: sequenceIndex,
104
+ message: JSON.stringify(letter.message),
105
+ cause_type: letter.cause.name,
106
+ cause_message: letter.cause.message,
107
+ diagnostics: JSON.stringify(diagnostics),
108
+ enqueued_at: String(letter.enqueuedAt),
109
+ last_touched: String(letter.lastTouched),
110
+ processing_started: null as string | null,
111
+ }
112
+ }
113
+
114
+ async function sequenceRows(k: KnexQueryable, seqId: string): Promise<any[]> {
115
+ return k(table)
116
+ .where({ processing_group: processingGroup, sequence_identifier: seqId })
117
+ .orderBy("sequence_index", "asc")
118
+ }
119
+
120
+ async function distinctSequences(k: KnexQueryable): Promise<string[]> {
121
+ const rows = await k(table)
122
+ .where({ processing_group: processingGroup })
123
+ .distinct("sequence_identifier")
124
+ return rows.map((r: any) => r.sequence_identifier)
125
+ }
126
+
127
+ return {
128
+ async enqueue(letter) {
129
+ const k = getKnex()
130
+ const existing = await sequenceRows(k, letter.sequenceIdentifier)
131
+ if (existing.length === 0) {
132
+ if ((await distinctSequences(k)).length >= maxSequences) {
133
+ throw new DeadLetterQueueOverflowError(`max sequences ${maxSequences} reached`)
134
+ }
135
+ } else if (existing.length >= maxSequenceSize) {
136
+ throw new DeadLetterQueueOverflowError(
137
+ `sequence "${letter.sequenceIdentifier}" has reached max size ${maxSequenceSize}`,
138
+ )
139
+ }
140
+ const idx = existing.length === 0 ? 0 : existing[existing.length - 1].sequence_index + 1
141
+ await k(table).insert(letterToRow(letter, idx, newId(processingGroup)))
142
+ },
143
+
144
+ async enqueueIfPresent(sequenceIdentifier, letterSupplier) {
145
+ const k = getKnex()
146
+ const existing = await sequenceRows(k, sequenceIdentifier)
147
+ if (existing.length === 0) return false
148
+ if (existing.length >= maxSequenceSize) {
149
+ throw new DeadLetterQueueOverflowError(
150
+ `sequence "${sequenceIdentifier}" has reached max size ${maxSequenceSize}`,
151
+ )
152
+ }
153
+ const idx = existing[existing.length - 1].sequence_index + 1
154
+ await k(table).insert(letterToRow(letterSupplier(), idx, newId(processingGroup)))
155
+ return true
156
+ },
157
+
158
+ async evict(_sequenceIdentifier, letter) {
159
+ const k = getKnex()
160
+ const id = (letter.diagnostics as Record<string, unknown>)[DL_ID]
161
+ if (typeof id !== "string") return
162
+ await k(table)
163
+ .where({ processing_group: processingGroup, dead_letter_id: id })
164
+ .delete()
165
+ },
166
+
167
+ async requeue(letter, update) {
168
+ const k = getKnex()
169
+ const id = (letter.diagnostics as Record<string, unknown>)[DL_ID]
170
+ if (typeof id !== "string") return
171
+ const { [DL_ID]: _omit, ...baseDiag } = letter.diagnostics as Record<string, unknown>
172
+ const cause = update?.cause ?? letter.cause
173
+ const diagnostics = update?.diagnostics ? { ...baseDiag, ...update.diagnostics } : baseDiag
174
+ await k(table)
175
+ .where({ processing_group: processingGroup, dead_letter_id: id })
176
+ .update({
177
+ cause_type: cause.name,
178
+ cause_message: cause.message,
179
+ diagnostics: JSON.stringify(diagnostics),
180
+ last_touched: String(Date.now()),
181
+ })
182
+ },
183
+
184
+ async contains(sequenceIdentifier) {
185
+ const k = getKnex()
186
+ const row = await k(table)
187
+ .where({ processing_group: processingGroup, sequence_identifier: sequenceIdentifier })
188
+ .first()
189
+ return row != null
190
+ },
191
+
192
+ async deadLetterSequence(sequenceIdentifier) {
193
+ const k = getKnex()
194
+ return (await sequenceRows(k, sequenceIdentifier)).map(rowToLetter)
195
+ },
196
+
197
+ async sequenceIdentifiers() {
198
+ return distinctSequences(getKnex())
199
+ },
200
+
201
+ async process(sequenceFilter, processingTask) {
202
+ const k = getKnex()
203
+ const candidates = (await distinctSequences(k)).filter(sequenceFilter)
204
+ if (candidates.length === 0) return false
205
+
206
+ // Pick the oldest sequence by its head letter's lastTouched, skipping
207
+ // sequences under an unexpired processing lease (multi-node safety).
208
+ const cutoff = Date.now() - claimDurationMs
209
+ let chosen: string | undefined
210
+ let oldest = Infinity
211
+ for (const seqId of candidates) {
212
+ const rows = await sequenceRows(k, seqId)
213
+ if (rows.length === 0) continue
214
+ const head = rows[0]
215
+ const leased = head.processing_started != null && Number(head.processing_started) > cutoff
216
+ if (leased) continue
217
+ if (Number(head.last_touched) < oldest) {
218
+ oldest = Number(head.last_touched)
219
+ chosen = seqId
220
+ }
221
+ }
222
+ if (!chosen) return false
223
+
224
+ // Claim the sequence head's lease for the duration of this pass.
225
+ const headRows = await sequenceRows(k, chosen)
226
+ await k(table)
227
+ .where({ processing_group: processingGroup, dead_letter_id: headRows[0].dead_letter_id })
228
+ .update({ processing_started: String(Date.now()) })
229
+
230
+ try {
231
+ for (const row of headRows) {
232
+ const letter = rowToLetter(row)
233
+ const decision: EnqueueDecision = await processingTask(letter)
234
+ if (decision.shouldEnqueue) {
235
+ await this.requeue(letter, { cause: decision.cause, diagnostics: decision.diagnostics })
236
+ return true
237
+ }
238
+ await this.evict(chosen, letter)
239
+ }
240
+ return true
241
+ } finally {
242
+ // Release any lease still set on a surviving head.
243
+ const remaining = await sequenceRows(k, chosen)
244
+ if (remaining.length > 0 && remaining[0].processing_started != null) {
245
+ await k(table)
246
+ .where({ processing_group: processingGroup, dead_letter_id: remaining[0].dead_letter_id })
247
+ .update({ processing_started: null })
248
+ }
249
+ }
250
+ },
251
+
252
+ async size() {
253
+ const k = getKnex()
254
+ const rows = await k(table)
255
+ .where({ processing_group: processingGroup })
256
+ .select("dead_letter_id")
257
+ return rows.length
258
+ },
259
+
260
+ async amountOfSequences() {
261
+ return (await distinctSequences(getKnex())).length
262
+ },
263
+
264
+ async clear() {
265
+ const k = getKnex()
266
+ await k(table)
267
+ .where({ processing_group: processingGroup })
268
+ .delete()
269
+ },
270
+
271
+ async isFull(sequenceIdentifier) {
272
+ const k = getKnex()
273
+ const rows = await sequenceRows(k, sequenceIdentifier)
274
+ if (rows.length > 0) return rows.length >= maxSequenceSize
275
+ return (await distinctSequences(k)).length >= maxSequences
276
+ },
277
+ }
278
+ }