@remit/queue-sidecar 0.0.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/src/store.ts ADDED
@@ -0,0 +1,519 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import Database from "better-sqlite3";
3
+
4
+ export interface QueueDefinition {
5
+ readonly name: string;
6
+ readonly fifo: boolean;
7
+ readonly visibilityTimeoutSeconds: number;
8
+ readonly deadLetterTargetName?: string;
9
+ readonly maxReceiveCount?: number;
10
+ readonly contentBasedDeduplication?: boolean;
11
+ }
12
+
13
+ export interface QueueRecord {
14
+ readonly name: string;
15
+ readonly fifo: boolean;
16
+ readonly visibilityTimeoutSeconds: number;
17
+ readonly deadLetterTargetName: string | null;
18
+ readonly maxReceiveCount: number | null;
19
+ readonly contentBasedDeduplication: boolean;
20
+ }
21
+
22
+ export interface SendResult {
23
+ readonly messageId: string;
24
+ readonly md5OfBody: string;
25
+ readonly sequenceNumber: string | null;
26
+ readonly deduplicated: boolean;
27
+ }
28
+
29
+ export interface ReceivedMessage {
30
+ readonly messageId: string;
31
+ readonly body: string;
32
+ readonly md5OfBody: string;
33
+ readonly receiptHandle: string;
34
+ readonly receiveCount: number;
35
+ readonly sentTimestamp: number;
36
+ readonly firstReceivedTimestamp: number;
37
+ readonly groupId: string | null;
38
+ readonly sequenceNumber: string | null;
39
+ }
40
+
41
+ export interface QueueAttributes {
42
+ readonly approximateNumberOfMessages: number;
43
+ readonly approximateNumberOfMessagesNotVisible: number;
44
+ readonly visibilityTimeout: number;
45
+ readonly fifoQueue: boolean;
46
+ readonly redrivePolicy: string | null;
47
+ }
48
+
49
+ interface QueueRow {
50
+ name: string;
51
+ fifo: number;
52
+ visibility_timeout_seconds: number;
53
+ dead_letter_target_name: string | null;
54
+ max_receive_count: number | null;
55
+ content_based_deduplication: number;
56
+ }
57
+
58
+ interface MessageRow {
59
+ id: number;
60
+ message_id: string;
61
+ queue_name: string;
62
+ body: string;
63
+ md5_body: string;
64
+ group_id: string | null;
65
+ dedup_id: string | null;
66
+ sequence_number: string | null;
67
+ receive_count: number;
68
+ visible_at: number;
69
+ receipt_handle: string | null;
70
+ sent_at: number;
71
+ first_received_at: number | null;
72
+ }
73
+
74
+ const DEDUP_WINDOW_MS = 5 * 60 * 1000;
75
+
76
+ const md5Hex = (value: string): string =>
77
+ createHash("md5").update(value, "utf8").digest("hex");
78
+
79
+ const toQueueRecord = (row: QueueRow): QueueRecord => ({
80
+ name: row.name,
81
+ fifo: row.fifo === 1,
82
+ visibilityTimeoutSeconds: row.visibility_timeout_seconds,
83
+ deadLetterTargetName: row.dead_letter_target_name,
84
+ maxReceiveCount: row.max_receive_count,
85
+ contentBasedDeduplication: row.content_based_deduplication === 1,
86
+ });
87
+
88
+ /**
89
+ * SQLite-backed store for the queue sidecar. Owns its own database file and
90
+ * schema, isolated from the application and search data (ADR constraint 4).
91
+ * Every operation the SQS surface exposes maps to a method here; the HTTP layer
92
+ * only translates the AWS Query protocol to and from these calls.
93
+ *
94
+ * FIFO ordering, visibility timeout, and dead-letter redrive are all decided
95
+ * here so the behaviour is testable without a running HTTP server.
96
+ */
97
+ export class QueueStore {
98
+ private readonly db: Database.Database;
99
+ private sequenceCounter: bigint;
100
+
101
+ constructor(filename: string) {
102
+ this.db = new Database(filename);
103
+ this.db.pragma("journal_mode = WAL");
104
+ this.db.pragma("busy_timeout = 5000");
105
+ // FULL, not NORMAL: the outbox path makes durability non-negotiable
106
+ // (ADR constraint 5) — a queued send lost is a mail the user believes
107
+ // left but never did. FULL flushes the WAL on every commit, so a
108
+ // committed send survives host power-loss, not only a process restart.
109
+ // At this queue's write volume the extra fsync cost is irrelevant.
110
+ this.db.pragma("synchronous = FULL");
111
+ this.migrate();
112
+ this.sequenceCounter = this.readMaxSequence();
113
+ }
114
+
115
+ private migrate(): void {
116
+ this.db.exec(`
117
+ CREATE TABLE IF NOT EXISTS queues (
118
+ name TEXT PRIMARY KEY,
119
+ fifo INTEGER NOT NULL DEFAULT 0,
120
+ visibility_timeout_seconds INTEGER NOT NULL DEFAULT 30,
121
+ dead_letter_target_name TEXT,
122
+ max_receive_count INTEGER,
123
+ content_based_deduplication INTEGER NOT NULL DEFAULT 0
124
+ );
125
+ CREATE TABLE IF NOT EXISTS messages (
126
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
127
+ message_id TEXT NOT NULL UNIQUE,
128
+ queue_name TEXT NOT NULL,
129
+ body TEXT NOT NULL,
130
+ md5_body TEXT NOT NULL,
131
+ group_id TEXT,
132
+ dedup_id TEXT,
133
+ sequence_number TEXT,
134
+ receive_count INTEGER NOT NULL DEFAULT 0,
135
+ visible_at INTEGER NOT NULL DEFAULT 0,
136
+ receipt_handle TEXT,
137
+ sent_at INTEGER NOT NULL,
138
+ first_received_at INTEGER
139
+ );
140
+ CREATE INDEX IF NOT EXISTS messages_queue_order
141
+ ON messages (queue_name, id);
142
+ CREATE TABLE IF NOT EXISTS dedup (
143
+ queue_name TEXT NOT NULL,
144
+ dedup_id TEXT NOT NULL,
145
+ message_id TEXT NOT NULL,
146
+ created_at INTEGER NOT NULL,
147
+ PRIMARY KEY (queue_name, dedup_id)
148
+ );
149
+ `);
150
+ }
151
+
152
+ private readMaxSequence(): bigint {
153
+ const row = this.db
154
+ .prepare(
155
+ "SELECT MAX(CAST(sequence_number AS INTEGER)) AS max FROM messages",
156
+ )
157
+ .get() as { max: number | null };
158
+ return BigInt(row.max ?? 0);
159
+ }
160
+
161
+ private nextSequence(): string {
162
+ this.sequenceCounter += 1n;
163
+ return this.sequenceCounter.toString();
164
+ }
165
+
166
+ close(): void {
167
+ this.db.close();
168
+ }
169
+
170
+ upsertQueue(def: QueueDefinition): QueueRecord {
171
+ this.db
172
+ .prepare(
173
+ `INSERT INTO queues (
174
+ name, fifo, visibility_timeout_seconds,
175
+ dead_letter_target_name, max_receive_count,
176
+ content_based_deduplication
177
+ ) VALUES (?, ?, ?, ?, ?, ?)
178
+ ON CONFLICT(name) DO UPDATE SET
179
+ fifo = excluded.fifo,
180
+ visibility_timeout_seconds = excluded.visibility_timeout_seconds,
181
+ dead_letter_target_name = excluded.dead_letter_target_name,
182
+ max_receive_count = excluded.max_receive_count,
183
+ content_based_deduplication = excluded.content_based_deduplication`,
184
+ )
185
+ .run(
186
+ def.name,
187
+ def.fifo ? 1 : 0,
188
+ def.visibilityTimeoutSeconds,
189
+ def.deadLetterTargetName ?? null,
190
+ def.maxReceiveCount ?? null,
191
+ def.contentBasedDeduplication ? 1 : 0,
192
+ );
193
+ const created = this.getQueue(def.name);
194
+ if (!created) throw new Error(`queue ${def.name} vanished after upsert`);
195
+ return created;
196
+ }
197
+
198
+ getQueue(name: string): QueueRecord | undefined {
199
+ const row = this.db
200
+ .prepare("SELECT * FROM queues WHERE name = ?")
201
+ .get(name) as QueueRow | undefined;
202
+ return row ? toQueueRecord(row) : undefined;
203
+ }
204
+
205
+ listQueues(): QueueRecord[] {
206
+ const rows = this.db
207
+ .prepare("SELECT * FROM queues ORDER BY name")
208
+ .all() as QueueRow[];
209
+ return rows.map(toQueueRecord);
210
+ }
211
+
212
+ sendMessage(input: {
213
+ queueName: string;
214
+ body: string;
215
+ groupId?: string;
216
+ deduplicationId?: string;
217
+ now?: number;
218
+ }): SendResult {
219
+ const queue = this.requireQueue(input.queueName);
220
+ const now = input.now ?? Date.now();
221
+ const md5 = md5Hex(input.body);
222
+
223
+ const invalid = validateFifoSend(queue, input);
224
+ if (invalid) throw invalid;
225
+
226
+ const dedupId = queue.fifo
227
+ ? (input.deduplicationId ??
228
+ (queue.contentBasedDeduplication ? md5 : undefined))
229
+ : undefined;
230
+
231
+ const send = this.db.transaction((): SendResult => {
232
+ if (dedupId) {
233
+ this.pruneDedup(input.queueName, now);
234
+ const existing = this.db
235
+ .prepare(
236
+ "SELECT message_id FROM dedup WHERE queue_name = ? AND dedup_id = ?",
237
+ )
238
+ .get(input.queueName, dedupId) as { message_id: string } | undefined;
239
+ if (existing) {
240
+ const seq = this.db
241
+ .prepare(
242
+ "SELECT sequence_number FROM messages WHERE message_id = ?",
243
+ )
244
+ .get(existing.message_id) as
245
+ | { sequence_number: string | null }
246
+ | undefined;
247
+ return {
248
+ messageId: existing.message_id,
249
+ md5OfBody: md5,
250
+ sequenceNumber: seq?.sequence_number ?? null,
251
+ deduplicated: true,
252
+ };
253
+ }
254
+ }
255
+
256
+ const messageId = randomUUID();
257
+ const sequenceNumber = queue.fifo ? this.nextSequence() : null;
258
+ this.db
259
+ .prepare(
260
+ `INSERT INTO messages (
261
+ message_id, queue_name, body, md5_body, group_id,
262
+ dedup_id, sequence_number, receive_count, visible_at,
263
+ receipt_handle, sent_at, first_received_at
264
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, NULL)`,
265
+ )
266
+ .run(
267
+ messageId,
268
+ input.queueName,
269
+ input.body,
270
+ md5,
271
+ input.groupId ?? null,
272
+ dedupId ?? null,
273
+ sequenceNumber,
274
+ now,
275
+ );
276
+
277
+ if (dedupId) {
278
+ this.db
279
+ .prepare(
280
+ "INSERT INTO dedup (queue_name, dedup_id, message_id, created_at) VALUES (?, ?, ?, ?)",
281
+ )
282
+ .run(input.queueName, dedupId, messageId, now);
283
+ }
284
+
285
+ return {
286
+ messageId,
287
+ md5OfBody: md5,
288
+ sequenceNumber,
289
+ deduplicated: false,
290
+ };
291
+ });
292
+
293
+ return send();
294
+ }
295
+
296
+ receiveMessages(input: {
297
+ queueName: string;
298
+ maxMessages: number;
299
+ visibilityTimeoutSeconds?: number;
300
+ now?: number;
301
+ }): ReceivedMessage[] {
302
+ const queue = this.requireQueue(input.queueName);
303
+ const now = input.now ?? Date.now();
304
+ const visibilityTimeout =
305
+ input.visibilityTimeoutSeconds ?? queue.visibilityTimeoutSeconds;
306
+
307
+ const receive = this.db.transaction((): ReceivedMessage[] => {
308
+ const inFlightGroups = new Set(
309
+ (
310
+ this.db
311
+ .prepare(
312
+ `SELECT DISTINCT group_id FROM messages
313
+ WHERE queue_name = ? AND group_id IS NOT NULL
314
+ AND visible_at > ?`,
315
+ )
316
+ .all(input.queueName, now) as { group_id: string }[]
317
+ ).map((r) => r.group_id),
318
+ );
319
+
320
+ const candidates = this.db
321
+ .prepare(
322
+ `SELECT * FROM messages
323
+ WHERE queue_name = ? AND visible_at <= ?
324
+ ORDER BY id ASC`,
325
+ )
326
+ .all(input.queueName, now) as MessageRow[];
327
+
328
+ const picked: ReceivedMessage[] = [];
329
+ const claimedGroups = new Set<string>();
330
+
331
+ for (const candidate of candidates) {
332
+ if (picked.length >= input.maxMessages) break;
333
+
334
+ if (
335
+ queue.maxReceiveCount !== null &&
336
+ queue.deadLetterTargetName !== null &&
337
+ candidate.receive_count >= queue.maxReceiveCount
338
+ ) {
339
+ this.moveToDeadLetter(candidate, queue.deadLetterTargetName, now);
340
+ continue;
341
+ }
342
+
343
+ if (queue.fifo && candidate.group_id !== null) {
344
+ if (inFlightGroups.has(candidate.group_id)) continue;
345
+ if (claimedGroups.has(candidate.group_id)) continue;
346
+ claimedGroups.add(candidate.group_id);
347
+ }
348
+
349
+ const receiptHandle = randomUUID();
350
+ const receiveCount = candidate.receive_count + 1;
351
+ const firstReceived = candidate.first_received_at ?? now;
352
+ this.db
353
+ .prepare(
354
+ `UPDATE messages SET
355
+ receive_count = ?, visible_at = ?, receipt_handle = ?,
356
+ first_received_at = ?
357
+ WHERE id = ?`,
358
+ )
359
+ .run(
360
+ receiveCount,
361
+ now + visibilityTimeout * 1000,
362
+ receiptHandle,
363
+ firstReceived,
364
+ candidate.id,
365
+ );
366
+
367
+ picked.push({
368
+ messageId: candidate.message_id,
369
+ body: candidate.body,
370
+ md5OfBody: candidate.md5_body,
371
+ receiptHandle,
372
+ receiveCount,
373
+ sentTimestamp: candidate.sent_at,
374
+ firstReceivedTimestamp: firstReceived,
375
+ groupId: candidate.group_id,
376
+ sequenceNumber: candidate.sequence_number,
377
+ });
378
+ }
379
+
380
+ return picked;
381
+ });
382
+
383
+ return receive();
384
+ }
385
+
386
+ deleteMessage(queueName: string, receiptHandle: string): void {
387
+ this.requireQueue(queueName);
388
+ this.db
389
+ .prepare(
390
+ "DELETE FROM messages WHERE queue_name = ? AND receipt_handle = ?",
391
+ )
392
+ .run(queueName, receiptHandle);
393
+ }
394
+
395
+ purgeQueue(queueName: string): void {
396
+ this.requireQueue(queueName);
397
+ this.db.prepare("DELETE FROM messages WHERE queue_name = ?").run(queueName);
398
+ }
399
+
400
+ getQueueAttributes(queueName: string, now = Date.now()): QueueAttributes {
401
+ const queue = this.requireQueue(queueName);
402
+ const visible = this.db
403
+ .prepare(
404
+ "SELECT COUNT(*) AS n FROM messages WHERE queue_name = ? AND visible_at <= ?",
405
+ )
406
+ .get(queueName, now) as { n: number };
407
+ const notVisible = this.db
408
+ .prepare(
409
+ "SELECT COUNT(*) AS n FROM messages WHERE queue_name = ? AND visible_at > ?",
410
+ )
411
+ .get(queueName, now) as { n: number };
412
+
413
+ return {
414
+ approximateNumberOfMessages: visible.n,
415
+ approximateNumberOfMessagesNotVisible: notVisible.n,
416
+ visibilityTimeout: queue.visibilityTimeoutSeconds,
417
+ fifoQueue: queue.fifo,
418
+ redrivePolicy:
419
+ queue.deadLetterTargetName !== null && queue.maxReceiveCount !== null
420
+ ? JSON.stringify({
421
+ deadLetterTargetArn: `arn:aws:sqs:local:000000000000:${queue.deadLetterTargetName}`,
422
+ maxReceiveCount: queue.maxReceiveCount,
423
+ })
424
+ : null,
425
+ };
426
+ }
427
+
428
+ private moveToDeadLetter(
429
+ message: MessageRow,
430
+ deadLetterName: string,
431
+ now: number,
432
+ ): void {
433
+ this.db
434
+ .prepare(
435
+ `UPDATE messages SET
436
+ queue_name = ?, receive_count = 0, visible_at = 0,
437
+ receipt_handle = NULL, first_received_at = NULL, sent_at = ?
438
+ WHERE id = ?`,
439
+ )
440
+ .run(deadLetterName, now, message.id);
441
+ }
442
+
443
+ private pruneDedup(queueName: string, now: number): void {
444
+ this.db
445
+ .prepare("DELETE FROM dedup WHERE queue_name = ? AND created_at < ?")
446
+ .run(queueName, now - DEDUP_WINDOW_MS);
447
+ }
448
+
449
+ private requireQueue(name: string): QueueRecord {
450
+ const queue = this.getQueue(name);
451
+ if (!queue) throw new QueueDoesNotExistError(name);
452
+ return queue;
453
+ }
454
+ }
455
+
456
+ /**
457
+ * An error that maps to an SQS Query wire fault. `code` is the SQS error code
458
+ * the SDK deserializes into a named exception; `senderFault` decides Sender vs
459
+ * Receiver (HTTP 400 vs 500).
460
+ */
461
+ export class SqsError extends Error {
462
+ constructor(
463
+ public readonly code: string,
464
+ message: string,
465
+ public readonly senderFault = true,
466
+ ) {
467
+ super(message);
468
+ this.name = new.target.name;
469
+ }
470
+ }
471
+
472
+ export class QueueDoesNotExistError extends SqsError {
473
+ constructor(public readonly queueName: string) {
474
+ super(
475
+ "AWS.SimpleQueueService.NonExistentQueue",
476
+ `The specified queue does not exist: ${queueName}`,
477
+ );
478
+ }
479
+ }
480
+
481
+ export class MissingParameterError extends SqsError {
482
+ constructor(message: string) {
483
+ super("MissingParameter", message);
484
+ }
485
+ }
486
+
487
+ export class InvalidParameterValueError extends SqsError {
488
+ constructor(message: string) {
489
+ super("InvalidParameterValue", message);
490
+ }
491
+ }
492
+
493
+ export interface FifoSendInput {
494
+ readonly groupId?: string;
495
+ readonly deduplicationId?: string;
496
+ }
497
+
498
+ /**
499
+ * The FIFO-send preconditions real SQS enforces, returned rather than thrown so
500
+ * the batch path can map a bad entry to a per-entry failure instead of failing
501
+ * the whole request. A standard queue has no preconditions.
502
+ */
503
+ export const validateFifoSend = (
504
+ queue: QueueRecord,
505
+ input: FifoSendInput,
506
+ ): SqsError | undefined => {
507
+ if (!queue.fifo) return undefined;
508
+ if (!input.groupId) {
509
+ return new MissingParameterError(
510
+ "The request must contain the parameter MessageGroupId.",
511
+ );
512
+ }
513
+ if (!input.deduplicationId && !queue.contentBasedDeduplication) {
514
+ return new InvalidParameterValueError(
515
+ "The queue should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly.",
516
+ );
517
+ }
518
+ return undefined;
519
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }