@kronos-ts/typeorm 0.1.4 → 0.2.1

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 { typeormTransactionManager, type TypeOrmDataSourceLike, type TypeOrmTransaction, } from "./typeorm-transaction-manager.js";
2
2
  export { typeormTokenStore, type TypeOrmManagerLike, } from "./typeorm-token-store.js";
3
+ export { typeormDeadLetterQueue, type TypeOrmDeadLetterQueueConfig, } from "./typeorm-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,yBAAyB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,GACxB,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EACL,iBAAiB,EACjB,KAAK,kBAAkB,GACxB,MAAM,0BAA0B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,yBAAyB,EACzB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,GACxB,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EACL,iBAAiB,EACjB,KAAK,kBAAkB,GACxB,MAAM,0BAA0B,CAAA;AAEjC,OAAO,EACL,sBAAsB,EACtB,KAAK,4BAA4B,GAClC,MAAM,gCAAgC,CAAA"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { typeormTransactionManager, } from "./typeorm-transaction-manager.js";
2
2
  export { typeormTokenStore, } from "./typeorm-token-store.js";
3
+ export { typeormDeadLetterQueue, } from "./typeorm-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,yBAAyB,GAG1B,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EACL,iBAAiB,GAElB,MAAM,0BAA0B,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,yBAAyB,GAG1B,MAAM,kCAAkC,CAAA;AAEzC,OAAO,EACL,iBAAiB,GAElB,MAAM,0BAA0B,CAAA;AAEjC,OAAO,EACL,sBAAsB,GAEvB,MAAM,gCAAgC,CAAA"}
@@ -0,0 +1,77 @@
1
+ import type { SequencedDeadLetterQueue } from "@kronos-ts/messaging";
2
+ import type { TypeOrmManagerLike } from "./typeorm-token-store.js";
3
+ /**
4
+ * Persistent {@link SequencedDeadLetterQueue} backed by TypeORM — a faithful
5
+ * translation of the Drizzle reference implementation to TypeORM's raw-SQL
6
+ * query style (`manager.query(sql, params)`), mirroring {@link typeormTokenStore}.
7
+ *
8
+ * Like {@link typeormTokenStore} it reads the active transaction via
9
+ * `getActiveTransaction()`, so enqueue/evict/requeue commit in the **same
10
+ * transaction as the token update** — a crash cannot advance the processor's
11
+ * token while losing the parked letter.
12
+ *
13
+ * The table is shared across processors and partitioned by `processingGroup`.
14
+ * Per-sequence FIFO order is held by a monotonic `sequence_index`; a
15
+ * `processing_started` lease column makes `process()` safe across multiple
16
+ * nodes (Axon parity).
17
+ *
18
+ * Expected table (`kronos_dead_letters`). Users may define this entity:
19
+ *
20
+ * ```typescript
21
+ * @Entity("kronos_dead_letters")
22
+ * export class KronosDeadLetterEntry {
23
+ * @PrimaryColumn({ name: "dead_letter_id" }) deadLetterId: string
24
+ * @Column({ name: "processing_group" }) processingGroup: string
25
+ * @Column({ name: "sequence_identifier" }) sequenceIdentifier: string
26
+ * @Column({ name: "sequence_index" }) sequenceIndex: number
27
+ * @Column({ type: "text" }) message: string
28
+ * @Column({ name: "cause_type", nullable: true }) causeType: string | null
29
+ * @Column({ name: "cause_message", type: "text", nullable: true }) causeMessage: string | null
30
+ * @Column({ type: "text" }) diagnostics: string
31
+ * @Column({ name: "enqueued_at" }) enqueuedAt: string
32
+ * @Column({ name: "last_touched" }) lastTouched: string
33
+ * @Column({ name: "processing_started", nullable: true }) processingStarted: string | null
34
+ * }
35
+ * ```
36
+ *
37
+ * Column set:
38
+ * - `dead_letter_id` (PK) — opaque persistent row id.
39
+ * - `processing_group` — the processor name; partitions the table.
40
+ * - `sequence_identifier` — the per-sequence FIFO key.
41
+ * - `sequence_index` (int) — monotonic ordering within a sequence.
42
+ * - `message` (text) — the {@link EventMessage} serialized as JSON.
43
+ * - `cause_type` (nullable) — `Error.name` of the failure cause.
44
+ * - `cause_message` (text, null)— `Error.message` of the failure cause.
45
+ * - `diagnostics` (text) — diagnostics map serialized as JSON.
46
+ * - `enqueued_at` — epoch-ms timestamp as a string.
47
+ * - `last_touched` — epoch-ms timestamp as a string.
48
+ * - `processing_started` (nullable) — epoch-ms lease timestamp as a string.
49
+ */
50
+ export interface TypeOrmDeadLetterQueueConfig {
51
+ /** Processing group (the processor name) this queue serves. */
52
+ processingGroup: string;
53
+ /** Table name. Default: "kronos_dead_letters". */
54
+ tableName?: string;
55
+ /** Maximum number of sequences. Default: 1024 (Axon parity). */
56
+ maxSequences?: number;
57
+ /** Maximum letters per sequence. Default: 1024 (Axon parity). */
58
+ maxSequenceSize?: number;
59
+ /** Lease duration for in-flight processing, ms. Default: 30000 (Axon parity). */
60
+ claimDurationMs?: number;
61
+ }
62
+ /**
63
+ * Creates a {@link SequencedDeadLetterQueue} backed by TypeORM.
64
+ *
65
+ * Uses raw SQL queries via the EntityManager for maximum compatibility.
66
+ * Participates in the active transaction via `getActiveTransaction()`.
67
+ *
68
+ * ```typescript
69
+ * import { typeormDeadLetterQueue } from "@kronos-ts/typeorm"
70
+ *
71
+ * const dlq = typeormDeadLetterQueue(dataSource.manager, {
72
+ * processingGroup: "my-processor",
73
+ * })
74
+ * ```
75
+ */
76
+ export declare function typeormDeadLetterQueue(manager: TypeOrmManagerLike, config: TypeOrmDeadLetterQueueConfig): SequencedDeadLetterQueue;
77
+ //# sourceMappingURL=typeorm-dead-letter-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typeorm-dead-letter-queue.d.ts","sourceRoot":"","sources":["../src/typeorm-dead-letter-queue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA+B,wBAAwB,EAAE,MAAM,sBAAsB,CAAA;AAGjG,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAElE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,MAAM,WAAW,4BAA4B;IAC3C,+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;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE,4BAA4B,GACnC,wBAAwB,CA2O1B"}
@@ -0,0 +1,215 @@
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 TypeORM.
12
+ *
13
+ * Uses raw SQL queries via the EntityManager for maximum compatibility.
14
+ * Participates in the active transaction via `getActiveTransaction()`.
15
+ *
16
+ * ```typescript
17
+ * import { typeormDeadLetterQueue } from "@kronos-ts/typeorm"
18
+ *
19
+ * const dlq = typeormDeadLetterQueue(dataSource.manager, {
20
+ * processingGroup: "my-processor",
21
+ * })
22
+ * ```
23
+ */
24
+ export function typeormDeadLetterQueue(manager, config) {
25
+ const { processingGroup } = config;
26
+ const table = config.tableName ?? "kronos_dead_letters";
27
+ const maxSequences = config.maxSequences ?? 1024;
28
+ const maxSequenceSize = config.maxSequenceSize ?? 1024;
29
+ const claimDurationMs = config.claimDurationMs ?? 30000;
30
+ function getManager() {
31
+ return getActiveTransaction() ?? manager;
32
+ }
33
+ function rowToLetter(row) {
34
+ const cause = new Error(row.cause_message ?? "");
35
+ if (row.cause_type)
36
+ cause.name = row.cause_type;
37
+ return {
38
+ message: JSON.parse(row.message),
39
+ cause,
40
+ enqueuedAt: Number(row.enqueued_at),
41
+ lastTouched: Number(row.last_touched),
42
+ diagnostics: { ...JSON.parse(row.diagnostics), [DL_ID]: row.dead_letter_id },
43
+ sequenceIdentifier: row.sequence_identifier,
44
+ };
45
+ }
46
+ async function sequenceRows(m, seqId) {
47
+ return m.query(`SELECT dead_letter_id, processing_group, sequence_identifier, sequence_index,
48
+ message, cause_type, cause_message, diagnostics,
49
+ enqueued_at, last_touched, processing_started
50
+ FROM ${table}
51
+ WHERE processing_group = $1 AND sequence_identifier = $2
52
+ ORDER BY sequence_index ASC`, [processingGroup, seqId]);
53
+ }
54
+ async function distinctSequences(m) {
55
+ const rows = await m.query(`SELECT DISTINCT sequence_identifier FROM ${table} WHERE processing_group = $1`, [processingGroup]);
56
+ return rows.map((r) => r.sequence_identifier);
57
+ }
58
+ async function insertLetter(m, letter, sequenceIndex, deadLetterId) {
59
+ const { [DL_ID]: _omit, ...diagnostics } = letter.diagnostics;
60
+ await m.query(`INSERT INTO ${table}
61
+ (dead_letter_id, processing_group, sequence_identifier, sequence_index,
62
+ message, cause_type, cause_message, diagnostics,
63
+ enqueued_at, last_touched, processing_started)
64
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL)`, [
65
+ deadLetterId,
66
+ processingGroup,
67
+ letter.sequenceIdentifier,
68
+ sequenceIndex,
69
+ JSON.stringify(letter.message),
70
+ letter.cause.name,
71
+ letter.cause.message,
72
+ JSON.stringify(diagnostics),
73
+ String(letter.enqueuedAt),
74
+ String(letter.lastTouched),
75
+ ]);
76
+ }
77
+ return {
78
+ async enqueue(letter) {
79
+ const m = getManager();
80
+ const existing = await sequenceRows(m, letter.sequenceIdentifier);
81
+ if (existing.length === 0) {
82
+ if ((await distinctSequences(m)).length >= maxSequences) {
83
+ throw new DeadLetterQueueOverflowError(`max sequences ${maxSequences} reached`);
84
+ }
85
+ }
86
+ else if (existing.length >= maxSequenceSize) {
87
+ throw new DeadLetterQueueOverflowError(`sequence "${letter.sequenceIdentifier}" has reached max size ${maxSequenceSize}`);
88
+ }
89
+ const idx = existing.length === 0 ? 0 : Number(existing[existing.length - 1].sequence_index) + 1;
90
+ await insertLetter(m, letter, idx, newId(processingGroup));
91
+ },
92
+ async enqueueIfPresent(sequenceIdentifier, letterSupplier) {
93
+ const m = getManager();
94
+ const existing = await sequenceRows(m, sequenceIdentifier);
95
+ if (existing.length === 0)
96
+ return false;
97
+ if (existing.length >= maxSequenceSize) {
98
+ throw new DeadLetterQueueOverflowError(`sequence "${sequenceIdentifier}" has reached max size ${maxSequenceSize}`);
99
+ }
100
+ const idx = Number(existing[existing.length - 1].sequence_index) + 1;
101
+ await insertLetter(m, letterSupplier(), idx, newId(processingGroup));
102
+ return true;
103
+ },
104
+ async evict(_sequenceIdentifier, letter) {
105
+ const m = getManager();
106
+ const id = letter.diagnostics[DL_ID];
107
+ if (typeof id !== "string")
108
+ return;
109
+ await m.query(`DELETE FROM ${table} WHERE processing_group = $1 AND dead_letter_id = $2`, [processingGroup, id]);
110
+ },
111
+ async requeue(letter, update) {
112
+ const m = getManager();
113
+ const id = letter.diagnostics[DL_ID];
114
+ if (typeof id !== "string")
115
+ return;
116
+ const { [DL_ID]: _omit, ...baseDiag } = letter.diagnostics;
117
+ const cause = update?.cause ?? letter.cause;
118
+ const diagnostics = update?.diagnostics ? { ...baseDiag, ...update.diagnostics } : baseDiag;
119
+ await m.query(`UPDATE ${table}
120
+ SET cause_type = $3, cause_message = $4, diagnostics = $5, last_touched = $6
121
+ WHERE processing_group = $1 AND dead_letter_id = $2`, [
122
+ processingGroup,
123
+ id,
124
+ cause.name,
125
+ cause.message,
126
+ JSON.stringify(diagnostics),
127
+ String(Date.now()),
128
+ ]);
129
+ },
130
+ async contains(sequenceIdentifier) {
131
+ const m = getManager();
132
+ const rows = await m.query(`SELECT dead_letter_id FROM ${table}
133
+ WHERE processing_group = $1 AND sequence_identifier = $2
134
+ LIMIT 1`, [processingGroup, sequenceIdentifier]);
135
+ return rows.length > 0;
136
+ },
137
+ async deadLetterSequence(sequenceIdentifier) {
138
+ const m = getManager();
139
+ return (await sequenceRows(m, sequenceIdentifier)).map(rowToLetter);
140
+ },
141
+ async sequenceIdentifiers() {
142
+ return distinctSequences(getManager());
143
+ },
144
+ async process(sequenceFilter, processingTask) {
145
+ const m = getManager();
146
+ const candidates = (await distinctSequences(m)).filter(sequenceFilter);
147
+ if (candidates.length === 0)
148
+ return false;
149
+ // Pick the oldest sequence by its head letter's lastTouched, skipping
150
+ // sequences under an unexpired processing lease (multi-node safety).
151
+ const cutoff = Date.now() - claimDurationMs;
152
+ let chosen;
153
+ let oldest = Infinity;
154
+ for (const seqId of candidates) {
155
+ const rows = await sequenceRows(m, seqId);
156
+ if (rows.length === 0)
157
+ continue;
158
+ const head = rows[0];
159
+ const leased = head.processing_started != null && Number(head.processing_started) > cutoff;
160
+ if (leased)
161
+ continue;
162
+ if (Number(head.last_touched) < oldest) {
163
+ oldest = Number(head.last_touched);
164
+ chosen = seqId;
165
+ }
166
+ }
167
+ if (!chosen)
168
+ return false;
169
+ // Claim the sequence head's lease for the duration of this pass.
170
+ const headRows = await sequenceRows(m, chosen);
171
+ await m.query(`UPDATE ${table} SET processing_started = $3
172
+ WHERE processing_group = $1 AND dead_letter_id = $2`, [processingGroup, headRows[0].dead_letter_id, String(Date.now())]);
173
+ try {
174
+ for (const row of headRows) {
175
+ const letter = rowToLetter(row);
176
+ const decision = await processingTask(letter);
177
+ if (decision.shouldEnqueue) {
178
+ await this.requeue(letter, { cause: decision.cause, diagnostics: decision.diagnostics });
179
+ return true;
180
+ }
181
+ await this.evict(chosen, letter);
182
+ }
183
+ return true;
184
+ }
185
+ finally {
186
+ // Release any lease still set on a surviving head.
187
+ const remaining = await sequenceRows(m, chosen);
188
+ if (remaining.length > 0 && remaining[0].processing_started != null) {
189
+ await m.query(`UPDATE ${table} SET processing_started = NULL
190
+ WHERE processing_group = $1 AND dead_letter_id = $2`, [processingGroup, remaining[0].dead_letter_id]);
191
+ }
192
+ }
193
+ },
194
+ async size() {
195
+ const m = getManager();
196
+ const rows = await m.query(`SELECT dead_letter_id FROM ${table} WHERE processing_group = $1`, [processingGroup]);
197
+ return rows.length;
198
+ },
199
+ async amountOfSequences() {
200
+ return (await distinctSequences(getManager())).length;
201
+ },
202
+ async clear() {
203
+ const m = getManager();
204
+ await m.query(`DELETE FROM ${table} WHERE processing_group = $1`, [processingGroup]);
205
+ },
206
+ async isFull(sequenceIdentifier) {
207
+ const m = getManager();
208
+ const rows = await sequenceRows(m, sequenceIdentifier);
209
+ if (rows.length > 0)
210
+ return rows.length >= maxSequenceSize;
211
+ return (await distinctSequences(m)).length >= maxSequences;
212
+ },
213
+ };
214
+ }
215
+ //# sourceMappingURL=typeorm-dead-letter-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typeorm-dead-letter-queue.js","sourceRoot":"","sources":["../src/typeorm-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/typeorm",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "TypeORM extension for Kronos.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,8 +49,8 @@
49
49
  }
50
50
  },
51
51
  "dependencies": {
52
- "@kronos-ts/common": "0.1.0",
53
- "@kronos-ts/messaging": "0.1.0"
52
+ "@kronos-ts/common": "0.1.1",
53
+ "@kronos-ts/messaging": "0.5.0"
54
54
  },
55
55
  "peerDependencies": {
56
56
  "typeorm": ">=0.3.0"
package/src/index.ts CHANGED
@@ -8,3 +8,8 @@ export {
8
8
  typeormTokenStore,
9
9
  type TypeOrmManagerLike,
10
10
  } from "./typeorm-token-store.js"
11
+
12
+ export {
13
+ typeormDeadLetterQueue,
14
+ type TypeOrmDeadLetterQueueConfig,
15
+ } from "./typeorm-dead-letter-queue.js"
@@ -0,0 +1,328 @@
1
+ import type { DeadLetter, EnqueueDecision, SequencedDeadLetterQueue } from "@kronos-ts/messaging"
2
+ import { getActiveTransaction, DeadLetterQueueOverflowError } from "@kronos-ts/messaging"
3
+ import type { TypeOrmTransaction } from "./typeorm-transaction-manager.js"
4
+ import type { TypeOrmManagerLike } from "./typeorm-token-store.js"
5
+
6
+ /**
7
+ * Persistent {@link SequencedDeadLetterQueue} backed by TypeORM — a faithful
8
+ * translation of the Drizzle reference implementation to TypeORM's raw-SQL
9
+ * query style (`manager.query(sql, params)`), mirroring {@link typeormTokenStore}.
10
+ *
11
+ * Like {@link typeormTokenStore} it reads the active transaction via
12
+ * `getActiveTransaction()`, so enqueue/evict/requeue commit in the **same
13
+ * transaction as the token update** — a crash cannot advance the processor's
14
+ * token while losing the parked letter.
15
+ *
16
+ * The table is shared across processors and partitioned by `processingGroup`.
17
+ * Per-sequence FIFO order is held by a monotonic `sequence_index`; a
18
+ * `processing_started` lease column makes `process()` safe across multiple
19
+ * nodes (Axon parity).
20
+ *
21
+ * Expected table (`kronos_dead_letters`). Users may define this entity:
22
+ *
23
+ * ```typescript
24
+ * @Entity("kronos_dead_letters")
25
+ * export class KronosDeadLetterEntry {
26
+ * @PrimaryColumn({ name: "dead_letter_id" }) deadLetterId: string
27
+ * @Column({ name: "processing_group" }) processingGroup: string
28
+ * @Column({ name: "sequence_identifier" }) sequenceIdentifier: string
29
+ * @Column({ name: "sequence_index" }) sequenceIndex: number
30
+ * @Column({ type: "text" }) message: string
31
+ * @Column({ name: "cause_type", nullable: true }) causeType: string | null
32
+ * @Column({ name: "cause_message", type: "text", nullable: true }) causeMessage: string | null
33
+ * @Column({ type: "text" }) diagnostics: string
34
+ * @Column({ name: "enqueued_at" }) enqueuedAt: string
35
+ * @Column({ name: "last_touched" }) lastTouched: string
36
+ * @Column({ name: "processing_started", nullable: true }) processingStarted: string | null
37
+ * }
38
+ * ```
39
+ *
40
+ * Column set:
41
+ * - `dead_letter_id` (PK) — opaque persistent row id.
42
+ * - `processing_group` — the processor name; partitions the table.
43
+ * - `sequence_identifier` — the per-sequence FIFO key.
44
+ * - `sequence_index` (int) — monotonic ordering within a sequence.
45
+ * - `message` (text) — the {@link EventMessage} serialized as JSON.
46
+ * - `cause_type` (nullable) — `Error.name` of the failure cause.
47
+ * - `cause_message` (text, null)— `Error.message` of the failure cause.
48
+ * - `diagnostics` (text) — diagnostics map serialized as JSON.
49
+ * - `enqueued_at` — epoch-ms timestamp as a string.
50
+ * - `last_touched` — epoch-ms timestamp as a string.
51
+ * - `processing_started` (nullable) — epoch-ms lease timestamp as a string.
52
+ */
53
+ export interface TypeOrmDeadLetterQueueConfig {
54
+ /** Processing group (the processor name) this queue serves. */
55
+ processingGroup: string
56
+ /** Table name. Default: "kronos_dead_letters". */
57
+ tableName?: string
58
+ /** Maximum number of sequences. Default: 1024 (Axon parity). */
59
+ maxSequences?: number
60
+ /** Maximum letters per sequence. Default: 1024 (Axon parity). */
61
+ maxSequenceSize?: number
62
+ /** Lease duration for in-flight processing, ms. Default: 30000 (Axon parity). */
63
+ claimDurationMs?: number
64
+ }
65
+
66
+ /** Reserved diagnostics key carrying the persistent row id across read → evict/requeue. */
67
+ const DL_ID = "__dlqId"
68
+
69
+ let idCounter = 0
70
+ function newId(group: string): string {
71
+ // Unique within the table: time + per-process counter + group.
72
+ idCounter += 1
73
+ return `${group}:${Date.now()}:${idCounter}`
74
+ }
75
+
76
+ /**
77
+ * Creates a {@link SequencedDeadLetterQueue} backed by TypeORM.
78
+ *
79
+ * Uses raw SQL queries via the EntityManager for maximum compatibility.
80
+ * Participates in the active transaction via `getActiveTransaction()`.
81
+ *
82
+ * ```typescript
83
+ * import { typeormDeadLetterQueue } from "@kronos-ts/typeorm"
84
+ *
85
+ * const dlq = typeormDeadLetterQueue(dataSource.manager, {
86
+ * processingGroup: "my-processor",
87
+ * })
88
+ * ```
89
+ */
90
+ export function typeormDeadLetterQueue(
91
+ manager: TypeOrmManagerLike,
92
+ config: TypeOrmDeadLetterQueueConfig,
93
+ ): SequencedDeadLetterQueue {
94
+ const { processingGroup } = config
95
+ const table = config.tableName ?? "kronos_dead_letters"
96
+ const maxSequences = config.maxSequences ?? 1024
97
+ const maxSequenceSize = config.maxSequenceSize ?? 1024
98
+ const claimDurationMs = config.claimDurationMs ?? 30000
99
+
100
+ function getManager(): TypeOrmManagerLike {
101
+ return getActiveTransaction<TypeOrmTransaction>() ?? manager
102
+ }
103
+
104
+ function rowToLetter(row: any): DeadLetter {
105
+ const cause = new Error(row.cause_message ?? "")
106
+ if (row.cause_type) cause.name = row.cause_type
107
+ return {
108
+ message: JSON.parse(row.message),
109
+ cause,
110
+ enqueuedAt: Number(row.enqueued_at),
111
+ lastTouched: Number(row.last_touched),
112
+ diagnostics: { ...JSON.parse(row.diagnostics), [DL_ID]: row.dead_letter_id },
113
+ sequenceIdentifier: row.sequence_identifier,
114
+ }
115
+ }
116
+
117
+ async function sequenceRows(m: TypeOrmManagerLike, seqId: string): Promise<any[]> {
118
+ return m.query(
119
+ `SELECT dead_letter_id, processing_group, sequence_identifier, sequence_index,
120
+ message, cause_type, cause_message, diagnostics,
121
+ enqueued_at, last_touched, processing_started
122
+ FROM ${table}
123
+ WHERE processing_group = $1 AND sequence_identifier = $2
124
+ ORDER BY sequence_index ASC`,
125
+ [processingGroup, seqId],
126
+ )
127
+ }
128
+
129
+ async function distinctSequences(m: TypeOrmManagerLike): Promise<string[]> {
130
+ const rows = await m.query(
131
+ `SELECT DISTINCT sequence_identifier FROM ${table} WHERE processing_group = $1`,
132
+ [processingGroup],
133
+ )
134
+ return rows.map((r: any) => r.sequence_identifier)
135
+ }
136
+
137
+ async function insertLetter(
138
+ m: TypeOrmManagerLike,
139
+ letter: DeadLetter,
140
+ sequenceIndex: number,
141
+ deadLetterId: string,
142
+ ): Promise<void> {
143
+ const { [DL_ID]: _omit, ...diagnostics } = letter.diagnostics as Record<string, unknown>
144
+ await m.query(
145
+ `INSERT INTO ${table}
146
+ (dead_letter_id, processing_group, sequence_identifier, sequence_index,
147
+ message, cause_type, cause_message, diagnostics,
148
+ enqueued_at, last_touched, processing_started)
149
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL)`,
150
+ [
151
+ deadLetterId,
152
+ processingGroup,
153
+ letter.sequenceIdentifier,
154
+ sequenceIndex,
155
+ JSON.stringify(letter.message),
156
+ letter.cause.name,
157
+ letter.cause.message,
158
+ JSON.stringify(diagnostics),
159
+ String(letter.enqueuedAt),
160
+ String(letter.lastTouched),
161
+ ],
162
+ )
163
+ }
164
+
165
+ return {
166
+ async enqueue(letter) {
167
+ const m = getManager()
168
+ const existing = await sequenceRows(m, letter.sequenceIdentifier)
169
+ if (existing.length === 0) {
170
+ if ((await distinctSequences(m)).length >= maxSequences) {
171
+ throw new DeadLetterQueueOverflowError(`max sequences ${maxSequences} reached`)
172
+ }
173
+ } else if (existing.length >= maxSequenceSize) {
174
+ throw new DeadLetterQueueOverflowError(
175
+ `sequence "${letter.sequenceIdentifier}" has reached max size ${maxSequenceSize}`,
176
+ )
177
+ }
178
+ const idx = existing.length === 0 ? 0 : Number(existing[existing.length - 1].sequence_index) + 1
179
+ await insertLetter(m, letter, idx, newId(processingGroup))
180
+ },
181
+
182
+ async enqueueIfPresent(sequenceIdentifier, letterSupplier) {
183
+ const m = getManager()
184
+ const existing = await sequenceRows(m, sequenceIdentifier)
185
+ if (existing.length === 0) return false
186
+ if (existing.length >= maxSequenceSize) {
187
+ throw new DeadLetterQueueOverflowError(
188
+ `sequence "${sequenceIdentifier}" has reached max size ${maxSequenceSize}`,
189
+ )
190
+ }
191
+ const idx = Number(existing[existing.length - 1].sequence_index) + 1
192
+ await insertLetter(m, letterSupplier(), idx, newId(processingGroup))
193
+ return true
194
+ },
195
+
196
+ async evict(_sequenceIdentifier, letter) {
197
+ const m = getManager()
198
+ const id = (letter.diagnostics as Record<string, unknown>)[DL_ID]
199
+ if (typeof id !== "string") return
200
+ await m.query(
201
+ `DELETE FROM ${table} WHERE processing_group = $1 AND dead_letter_id = $2`,
202
+ [processingGroup, id],
203
+ )
204
+ },
205
+
206
+ async requeue(letter, update) {
207
+ const m = getManager()
208
+ const id = (letter.diagnostics as Record<string, unknown>)[DL_ID]
209
+ if (typeof id !== "string") return
210
+ const { [DL_ID]: _omit, ...baseDiag } = letter.diagnostics as Record<string, unknown>
211
+ const cause = update?.cause ?? letter.cause
212
+ const diagnostics = update?.diagnostics ? { ...baseDiag, ...update.diagnostics } : baseDiag
213
+ await m.query(
214
+ `UPDATE ${table}
215
+ SET cause_type = $3, cause_message = $4, diagnostics = $5, last_touched = $6
216
+ WHERE processing_group = $1 AND dead_letter_id = $2`,
217
+ [
218
+ processingGroup,
219
+ id,
220
+ cause.name,
221
+ cause.message,
222
+ JSON.stringify(diagnostics),
223
+ String(Date.now()),
224
+ ],
225
+ )
226
+ },
227
+
228
+ async contains(sequenceIdentifier) {
229
+ const m = getManager()
230
+ const rows = await m.query(
231
+ `SELECT dead_letter_id FROM ${table}
232
+ WHERE processing_group = $1 AND sequence_identifier = $2
233
+ LIMIT 1`,
234
+ [processingGroup, sequenceIdentifier],
235
+ )
236
+ return rows.length > 0
237
+ },
238
+
239
+ async deadLetterSequence(sequenceIdentifier) {
240
+ const m = getManager()
241
+ return (await sequenceRows(m, sequenceIdentifier)).map(rowToLetter)
242
+ },
243
+
244
+ async sequenceIdentifiers() {
245
+ return distinctSequences(getManager())
246
+ },
247
+
248
+ async process(sequenceFilter, processingTask) {
249
+ const m = getManager()
250
+ const candidates = (await distinctSequences(m)).filter(sequenceFilter)
251
+ if (candidates.length === 0) return false
252
+
253
+ // Pick the oldest sequence by its head letter's lastTouched, skipping
254
+ // sequences under an unexpired processing lease (multi-node safety).
255
+ const cutoff = Date.now() - claimDurationMs
256
+ let chosen: string | undefined
257
+ let oldest = Infinity
258
+ for (const seqId of candidates) {
259
+ const rows = await sequenceRows(m, seqId)
260
+ if (rows.length === 0) continue
261
+ const head = rows[0]
262
+ const leased = head.processing_started != null && Number(head.processing_started) > cutoff
263
+ if (leased) continue
264
+ if (Number(head.last_touched) < oldest) {
265
+ oldest = Number(head.last_touched)
266
+ chosen = seqId
267
+ }
268
+ }
269
+ if (!chosen) return false
270
+
271
+ // Claim the sequence head's lease for the duration of this pass.
272
+ const headRows = await sequenceRows(m, chosen)
273
+ await m.query(
274
+ `UPDATE ${table} SET processing_started = $3
275
+ WHERE processing_group = $1 AND dead_letter_id = $2`,
276
+ [processingGroup, headRows[0].dead_letter_id, String(Date.now())],
277
+ )
278
+
279
+ try {
280
+ for (const row of headRows) {
281
+ const letter = rowToLetter(row)
282
+ const decision: EnqueueDecision = await processingTask(letter)
283
+ if (decision.shouldEnqueue) {
284
+ await this.requeue(letter, { cause: decision.cause, diagnostics: decision.diagnostics })
285
+ return true
286
+ }
287
+ await this.evict(chosen, letter)
288
+ }
289
+ return true
290
+ } finally {
291
+ // Release any lease still set on a surviving head.
292
+ const remaining = await sequenceRows(m, chosen)
293
+ if (remaining.length > 0 && remaining[0].processing_started != null) {
294
+ await m.query(
295
+ `UPDATE ${table} SET processing_started = NULL
296
+ WHERE processing_group = $1 AND dead_letter_id = $2`,
297
+ [processingGroup, remaining[0].dead_letter_id],
298
+ )
299
+ }
300
+ }
301
+ },
302
+
303
+ async size() {
304
+ const m = getManager()
305
+ const rows = await m.query(
306
+ `SELECT dead_letter_id FROM ${table} WHERE processing_group = $1`,
307
+ [processingGroup],
308
+ )
309
+ return rows.length
310
+ },
311
+
312
+ async amountOfSequences() {
313
+ return (await distinctSequences(getManager())).length
314
+ },
315
+
316
+ async clear() {
317
+ const m = getManager()
318
+ await m.query(`DELETE FROM ${table} WHERE processing_group = $1`, [processingGroup])
319
+ },
320
+
321
+ async isFull(sequenceIdentifier) {
322
+ const m = getManager()
323
+ const rows = await sequenceRows(m, sequenceIdentifier)
324
+ if (rows.length > 0) return rows.length >= maxSequenceSize
325
+ return (await distinctSequences(m)).length >= maxSequences
326
+ },
327
+ }
328
+ }