@remit/drizzle-service 0.0.15 → 0.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -242,7 +242,7 @@ describe("runDrizzleCascadeDelete", () => {
242
242
  quarantinedAt: NOW,
243
243
  attempts: 3,
244
244
  failureStage: "BodyParse",
245
- failureCode: "UnterminatedMultipartBoundary",
245
+ failureCode: "UnreadableBody",
246
246
  failureMessage: "multipart boundary was never closed",
247
247
  workerVersion: "worker 1.0.0",
248
248
  structure: [{ depth: 0, contentType: "multipart/mixed" }],
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { deriveQuarantineId } from "@remit/data-ports/id";
3
4
  import { quarantineTable } from "../schema/quarantine.js";
4
5
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
6
  import { QuarantineRepo } from "./quarantine.js";
@@ -26,7 +27,7 @@ describe("QuarantineRepo", () => {
26
27
  quarantinedAt: now,
27
28
  attempts: 3,
28
29
  failureStage: "BodyParse",
29
- failureCode: "UnterminatedMultipartBoundary",
30
+ failureCode: "UnreadableBody",
30
31
  failureMessage: "multipart boundary was never closed",
31
32
  workerVersion: "worker 1.0.0",
32
33
  structure: [{ depth: 0, contentType: "multipart/mixed" }],
@@ -109,4 +110,83 @@ describe("QuarantineRepo", () => {
109
110
  assert.equal(entry.sizeBytes, undefined);
110
111
  assert.equal(entry.messageIdHash, undefined);
111
112
  });
113
+
114
+ test("re-quarantining the same message rewrites one row, never adds another", async () => {
115
+ const accountConfigId = randomId();
116
+ const identity = {
117
+ accountConfigId,
118
+ accountId: randomId(),
119
+ mailboxId: randomId(),
120
+ uidValidity: 1_712_000_000,
121
+ uid: 40217,
122
+ mailboxPath: "INBOX",
123
+ attempts: 1,
124
+ failureStage: "BodyParse" as const,
125
+ failureCode: "UnreadableBody" as const,
126
+ failureMessage: "the parser said no",
127
+ workerVersion: "sha-abc",
128
+ };
129
+
130
+ await repo.upsert({ ...identity, quarantinedAt: 1_000 });
131
+ await repo.upsert({ ...identity, quarantinedAt: 2_000, attempts: 4 });
132
+
133
+ const entries = await repo.listByAccountConfigId(accountConfigId);
134
+
135
+ assert.equal(entries.length, 1);
136
+ assert.equal(entries[0].attempts, 4);
137
+ assert.equal(entries[0].quarantinedAt, 2_000);
138
+ });
139
+
140
+ test("keeps the id derived from the message, not the random column default", async () => {
141
+ const accountConfigId = randomId();
142
+ const identity = {
143
+ accountConfigId,
144
+ accountId: randomId(),
145
+ mailboxId: randomId(),
146
+ uidValidity: 1_712_000_000,
147
+ uid: 40217,
148
+ mailboxPath: "INBOX",
149
+ quarantinedAt: 1_000,
150
+ attempts: 1,
151
+ failureStage: "BodyParse" as const,
152
+ failureCode: "UnreadableBody" as const,
153
+ failureMessage: "the parser said no",
154
+ workerVersion: "sha-abc",
155
+ };
156
+
157
+ await repo.upsert(identity);
158
+ const [entry] = await repo.listByAccountConfigId(accountConfigId);
159
+
160
+ assert.equal(
161
+ entry.quarantineId,
162
+ deriveQuarantineId(
163
+ identity.accountId,
164
+ identity.mailboxId,
165
+ identity.uidValidity,
166
+ identity.uid,
167
+ ),
168
+ );
169
+ });
170
+
171
+ test("defaults the MIME tree to empty when the message failed before its shape was read", async () => {
172
+ const accountConfigId = randomId();
173
+ await repo.upsert({
174
+ accountConfigId,
175
+ accountId: randomId(),
176
+ mailboxId: randomId(),
177
+ uidValidity: 1_712_000_000,
178
+ uid: 1,
179
+ mailboxPath: "INBOX",
180
+ quarantinedAt: 1_000,
181
+ attempts: 1,
182
+ failureStage: "BodyParse",
183
+ failureCode: "UnreadableBody",
184
+ failureMessage: "the parser said no",
185
+ workerVersion: "sha-abc",
186
+ });
187
+
188
+ const [entry] = await repo.listByAccountConfigId(accountConfigId);
189
+
190
+ assert.deepEqual(entry.structure, []);
191
+ });
112
192
  });
@@ -2,7 +2,9 @@ import type {
2
2
  IQuarantineRepository,
3
3
  QuarantineItem,
4
4
  QuarantineMimeNodeItem,
5
+ QuarantineUpsertInput,
5
6
  } from "@remit/data-ports";
7
+ import { deriveQuarantineId } from "@remit/data-ports/id";
6
8
  import { desc, eq } from "drizzle-orm";
7
9
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
10
  import { quarantineTable } from "../schema/quarantine.js";
@@ -38,8 +40,8 @@ function rowToItem(row: typeof quarantineTable.$inferSelect): QuarantineItem {
38
40
  }
39
41
 
40
42
  /**
41
- * Read side of the message quarantine (issue #72). The rows are written by the
42
- * sync worker; nothing in the API process creates or clears one.
43
+ * The message quarantine (issue #72). Written by the sync worker, read by the
44
+ * settings surface; nothing in the API process creates or clears a row.
43
45
  */
44
46
  export class QuarantineRepo implements IQuarantineRepository {
45
47
  constructor(private db: DB) {}
@@ -54,4 +56,48 @@ export class QuarantineRepo implements IQuarantineRepository {
54
56
  .orderBy(desc(quarantineTable.quarantinedAt));
55
57
  return rows.map(rowToItem);
56
58
  };
59
+
60
+ upsert = async (input: QuarantineUpsertInput): Promise<void> => {
61
+ const quarantineId = deriveQuarantineId(
62
+ input.accountId,
63
+ input.mailboxId,
64
+ input.uidValidity,
65
+ input.uid,
66
+ );
67
+ const now = Date.now();
68
+ const columns = {
69
+ accountConfigId: input.accountConfigId,
70
+ accountId: input.accountId,
71
+ mailboxId: input.mailboxId,
72
+ uidValidity: input.uidValidity,
73
+ uid: input.uid,
74
+ mailboxRole: input.mailboxRole ?? null,
75
+ mailboxPath: input.mailboxPath,
76
+ quarantinedAt: input.quarantinedAt,
77
+ attempts: input.attempts,
78
+ failureStage: input.failureStage,
79
+ failureCode: input.failureCode,
80
+ failureMessage: input.failureMessage,
81
+ failurePartPath: input.failurePartPath ?? null,
82
+ workerVersion: input.workerVersion,
83
+ contentType: input.contentType ?? null,
84
+ transferEncoding: input.transferEncoding ?? null,
85
+ charset: input.charset ?? null,
86
+ sizeBytes: input.sizeBytes ?? null,
87
+ structure: input.structure ?? [],
88
+ messageIdHash: input.messageIdHash ?? null,
89
+ };
90
+
91
+ // `quarantinedAt` is deliberately part of the update set: a re-quarantine
92
+ // is the message being set aside again, and the list is ordered by it.
93
+ // `createdAt` is not, so the row keeps saying when the message first
94
+ // failed.
95
+ await this.db
96
+ .insert(quarantineTable)
97
+ .values({ quarantineId, ...columns, createdAt: now, updatedAt: now })
98
+ .onConflictDoUpdate({
99
+ target: quarantineTable.quarantineId,
100
+ set: { ...columns, updatedAt: now },
101
+ });
102
+ };
57
103
  }