@remit/drizzle-service 0.0.14 → 0.0.15

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.14",
3
+ "version": "0.0.15",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ export {
40
40
  } from "./repos/message.js";
41
41
  export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
42
42
  export { MessageLabelRepo } from "./repos/message-label.js";
43
+ export { QuarantineRepo } from "./repos/quarantine.js";
43
44
  export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
44
45
  export { DrizzleUnitOfWork } from "./repos/unit-of-work.js";
45
46
  export * from "./schema/i4-account-config.js";
@@ -54,6 +55,7 @@ export * from "./schema/i4-organize-job-request.js";
54
55
  export * from "./schema/i4-outbox-message.js";
55
56
  export * from "./schema/message-data.js";
56
57
  export { messageDataSchema } from "./schema/message-data.js";
58
+ export * from "./schema/quarantine.js";
57
59
  export {
58
60
  createSqliteDatabase,
59
61
  type SqliteClient,
@@ -11,6 +11,7 @@ import {
11
11
  envelopeTable,
12
12
  mailboxTable,
13
13
  messageTable,
14
+ quarantineTable,
14
15
  } from "../schema.js";
15
16
  import { createSqliteTestDb } from "../test-db-sqlite.js";
16
17
  import {
@@ -22,11 +23,13 @@ import {
22
23
  // this harness pushes must live on a real file (not the in-memory default) for
23
24
  // the deleter's own connection to see it. `deleteMessageSubtree` touches every
24
25
  // message-data child table, so push the full message-data schema plus the
25
- // account and mailbox containers.
26
+ // account and mailbox containers, plus quarantine — the cascade deletes those
27
+ // rows set-wise by account, so the table has to exist here too.
26
28
  const CASCADE_SCHEMA = {
27
29
  ...messageDataSchema,
28
30
  account: accountTable,
29
31
  mailbox: mailboxTable,
32
+ quarantine: quarantineTable,
30
33
  };
31
34
 
32
35
  const log = { info: () => {} };
@@ -16,6 +16,7 @@ import {
16
16
  messageTable,
17
17
  outboxMessageTable,
18
18
  outboxTable,
19
+ quarantineTable,
19
20
  threadMessageTable,
20
21
  } from "../schema.js";
21
22
  import { createTestDb, type TestDb } from "../test-db.js";
@@ -40,6 +41,7 @@ const ROOT_BODY_PART_ID = "00000000-0000-4000-8000-000000000002";
40
41
  const MESSAGE_FLAG_ID = "00000000-0000-4000-8000-000000000003";
41
42
  const BODY_PART_ID = "00000000-0000-4000-8000-000000000004";
42
43
  const OTHER_MAILBOX_ID = "mbx-cascade-2";
44
+ const QUARANTINE_ID = "quarantine-cascade-1";
43
45
 
44
46
  describe("runDrizzleCascadeDelete", () => {
45
47
  let db: TestDb;
@@ -228,6 +230,25 @@ describe("runDrizzleCascadeDelete", () => {
228
230
  updatedAt: NOW,
229
231
  },
230
232
  ]);
233
+
234
+ await db.insert(quarantineTable).values({
235
+ quarantineId: QUARANTINE_ID,
236
+ accountConfigId: ACCOUNT_CONFIG_ID,
237
+ accountId: ACCOUNT_ID,
238
+ mailboxId: MAILBOX_ID,
239
+ uidValidity: 1_712_000_000,
240
+ uid: 40217,
241
+ mailboxPath: "Clients/Acme Holdings",
242
+ quarantinedAt: NOW,
243
+ attempts: 3,
244
+ failureStage: "BodyParse",
245
+ failureCode: "UnterminatedMultipartBoundary",
246
+ failureMessage: "multipart boundary was never closed",
247
+ workerVersion: "worker 1.0.0",
248
+ structure: [{ depth: 0, contentType: "multipart/mixed" }],
249
+ createdAt: NOW,
250
+ updatedAt: NOW,
251
+ });
231
252
  });
232
253
 
233
254
  after(async () => {
@@ -397,6 +418,16 @@ describe("runDrizzleCascadeDelete", () => {
397
418
  0,
398
419
  "both the Seen and Flagged markers (composite key) must be deleted",
399
420
  );
421
+ assert.equal(
422
+ (
423
+ await db
424
+ .select()
425
+ .from(quarantineTable)
426
+ .where(eq(quarantineTable.quarantineId, QUARANTINE_ID))
427
+ ).length,
428
+ 0,
429
+ "quarantine rows carry the user's folder names and parser output, so account deletion must take them",
430
+ );
400
431
  });
401
432
 
402
433
  test("emits a message.removed outbox row for search-index cleanup", async () => {
@@ -9,6 +9,7 @@ import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
9
9
  import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
10
10
  import { outboxMessageTable } from "../schema/i4-outbox-message.js";
11
11
  import { messageDataSchema } from "../schema/message-data.js";
12
+ import { quarantineTable } from "../schema/quarantine.js";
12
13
  import { threadMessageTable } from "../schema/thread-message.js";
13
14
  import { createSqliteDatabase } from "../sqlite-client.js";
14
15
  import { runInTransaction } from "../tx.js";
@@ -151,6 +152,13 @@ export const runDrizzleCascadeDelete = async (
151
152
 
152
153
  const accountIds = (grouped.get("Account") ?? []).map((k) => k.accountId);
153
154
  if (accountIds.length > 0) {
155
+ // Quarantine rows are deleted set-wise by account rather than from an
156
+ // enumerated key, the way message subtrees are. They carry the user's
157
+ // own folder names and parser output quoting the message, so account
158
+ // deletion has to take them even though nothing enumerates them.
159
+ await tx
160
+ .delete(quarantineTable)
161
+ .where(inArray(quarantineTable.accountId, accountIds));
154
162
  await tx
155
163
  .delete(accountTable)
156
164
  .where(inArray(accountTable.accountId, accountIds));
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { quarantineTable } from "../schema/quarantine.js";
4
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
+ import { QuarantineRepo } from "./quarantine.js";
6
+
7
+ describe("QuarantineRepo", () => {
8
+ let db: TestDb;
9
+ let close: () => Promise<void>;
10
+ let repo: QuarantineRepo;
11
+
12
+ const seed = async (
13
+ overrides: Partial<typeof quarantineTable.$inferInsert> & {
14
+ accountConfigId: string;
15
+ },
16
+ ): Promise<string> => {
17
+ const quarantineId = randomId();
18
+ const now = Date.now();
19
+ await db.insert(quarantineTable).values({
20
+ quarantineId,
21
+ accountId: randomId(),
22
+ mailboxId: randomId(),
23
+ uidValidity: 1_712_000_000,
24
+ uid: 40217,
25
+ mailboxPath: "INBOX",
26
+ quarantinedAt: now,
27
+ attempts: 3,
28
+ failureStage: "BodyParse",
29
+ failureCode: "UnterminatedMultipartBoundary",
30
+ failureMessage: "multipart boundary was never closed",
31
+ workerVersion: "worker 1.0.0",
32
+ structure: [{ depth: 0, contentType: "multipart/mixed" }],
33
+ createdAt: now,
34
+ updatedAt: now,
35
+ ...overrides,
36
+ });
37
+ return quarantineId;
38
+ };
39
+
40
+ before(async () => {
41
+ ({ db, close } = await createTestDb());
42
+ repo = new QuarantineRepo(db as never);
43
+ });
44
+
45
+ after(async () => {
46
+ await close();
47
+ });
48
+
49
+ test("lists one user's entries newest first", async () => {
50
+ const accountConfigId = randomId();
51
+ const older = await seed({ accountConfigId, quarantinedAt: 1_000 });
52
+ const newer = await seed({ accountConfigId, quarantinedAt: 2_000 });
53
+
54
+ const entries = await repo.listByAccountConfigId(accountConfigId);
55
+
56
+ assert.deepEqual(
57
+ entries.map((entry) => entry.quarantineId),
58
+ [newer, older],
59
+ );
60
+ });
61
+
62
+ test("never returns another user's entries", async () => {
63
+ const mine = randomId();
64
+ await seed({ accountConfigId: mine });
65
+ await seed({ accountConfigId: randomId() });
66
+
67
+ const entries = await repo.listByAccountConfigId(mine);
68
+
69
+ assert.equal(entries.length, 1);
70
+ assert.equal(entries[0].accountConfigId, mine);
71
+ });
72
+
73
+ test("returns the MIME tree as the pre-order walk it was written as", async () => {
74
+ const accountConfigId = randomId();
75
+ await seed({
76
+ accountConfigId,
77
+ structure: [
78
+ { depth: 0, contentType: "multipart/mixed" },
79
+ { depth: 1, contentType: "text/plain" },
80
+ { depth: 1, contentType: "application/pdf" },
81
+ ],
82
+ });
83
+
84
+ const [entry] = await repo.listByAccountConfigId(accountConfigId);
85
+
86
+ assert.deepEqual(entry.structure, [
87
+ { depth: 0, contentType: "multipart/mixed" },
88
+ { depth: 1, contentType: "text/plain" },
89
+ { depth: 1, contentType: "application/pdf" },
90
+ ]);
91
+ });
92
+
93
+ test("carries the optional diagnostics as absent, not null", async () => {
94
+ const accountConfigId = randomId();
95
+ await seed({ accountConfigId });
96
+
97
+ const [entry] = await repo.listByAccountConfigId(accountConfigId);
98
+
99
+ // A folder nobody appointed a role to, a whole-body failure and an
100
+ // undeclared charset are all ordinary. The API contract makes them
101
+ // optional, so a null row value must not leak through as `null`.
102
+ assert.equal(entry.mailboxRole, undefined);
103
+ assert.equal(entry.failurePartPath, undefined);
104
+ assert.equal(entry.charset, undefined);
105
+ // The message-shape fields come off one optional BODYSTRUCTURE, so a
106
+ // message that failed before it was read carries none of them.
107
+ assert.equal(entry.contentType, undefined);
108
+ assert.equal(entry.transferEncoding, undefined);
109
+ assert.equal(entry.sizeBytes, undefined);
110
+ assert.equal(entry.messageIdHash, undefined);
111
+ });
112
+ });
@@ -0,0 +1,57 @@
1
+ import type {
2
+ IQuarantineRepository,
3
+ QuarantineItem,
4
+ QuarantineMimeNodeItem,
5
+ } from "@remit/data-ports";
6
+ import { desc, eq } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import { quarantineTable } from "../schema/quarantine.js";
9
+
10
+ type DB = NodePgDatabase<Record<string, unknown>>;
11
+
12
+ function rowToItem(row: typeof quarantineTable.$inferSelect): QuarantineItem {
13
+ return {
14
+ quarantineId: row.quarantineId,
15
+ accountConfigId: row.accountConfigId,
16
+ accountId: row.accountId,
17
+ mailboxId: row.mailboxId,
18
+ uidValidity: row.uidValidity,
19
+ uid: row.uid,
20
+ mailboxRole: row.mailboxRole ?? undefined,
21
+ mailboxPath: row.mailboxPath,
22
+ quarantinedAt: row.quarantinedAt,
23
+ attempts: row.attempts,
24
+ failureStage: row.failureStage,
25
+ failureCode: row.failureCode,
26
+ failureMessage: row.failureMessage,
27
+ failurePartPath: row.failurePartPath ?? undefined,
28
+ workerVersion: row.workerVersion,
29
+ contentType: row.contentType ?? undefined,
30
+ transferEncoding: row.transferEncoding ?? undefined,
31
+ charset: row.charset ?? undefined,
32
+ sizeBytes: row.sizeBytes ?? undefined,
33
+ structure: row.structure as QuarantineMimeNodeItem[],
34
+ messageIdHash: row.messageIdHash ?? undefined,
35
+ createdAt: row.createdAt,
36
+ updatedAt: row.updatedAt,
37
+ };
38
+ }
39
+
40
+ /**
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
+ */
44
+ export class QuarantineRepo implements IQuarantineRepository {
45
+ constructor(private db: DB) {}
46
+
47
+ listByAccountConfigId = async (
48
+ accountConfigId: string,
49
+ ): Promise<QuarantineItem[]> => {
50
+ const rows = await this.db
51
+ .select()
52
+ .from(quarantineTable)
53
+ .where(eq(quarantineTable.accountConfigId, accountConfigId))
54
+ .orderBy(desc(quarantineTable.quarantinedAt));
55
+ return rows.map(rowToItem);
56
+ };
57
+ }
@@ -0,0 +1,3 @@
1
+ import { entities } from "./active-entities.js";
2
+
3
+ export const quarantineTable = entities.quarantines;
package/src/schema.ts CHANGED
@@ -25,4 +25,5 @@ export * from "./schema/i4-message-placement-move.js";
25
25
  export * from "./schema/i4-organize-job-request.js";
26
26
  export * from "./schema/i4-outbox-message.js";
27
27
  export * from "./schema/message-data.js";
28
+ export * from "./schema/quarantine.js";
28
29
  export { threadMessageTable } from "./schema/thread-message.js";