@remit/drizzle-service 0.0.15 → 0.0.17

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.17",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { decodeToken, encodeToken, resultList } from "./pagination.js";
4
+
5
+ describe("continuation token decoding", () => {
6
+ test("round-trips a minted token", () => {
7
+ const token = encodeToken({ createdAt: 42, accountId: "a-1" });
8
+ assert.deepEqual(decodeToken(token), { createdAt: 42, accountId: "a-1" });
9
+ });
10
+
11
+ test("decodes a standard base64 token when asked", () => {
12
+ const token = Buffer.from(JSON.stringify({ id: "x" })).toString("base64");
13
+ assert.deepEqual(decodeToken(token, "base64"), { id: "x" });
14
+ });
15
+
16
+ for (const [label, token] of [
17
+ ["unparseable", "not-a-cursor"],
18
+ ["a bare number", Buffer.from("123").toString("base64url")],
19
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
20
+ ["JSON null", Buffer.from("null").toString("base64url")],
21
+ ] as const) {
22
+ test(`rejects ${label}`, () => {
23
+ assert.throws(
24
+ () => decodeToken(token),
25
+ (error: unknown) => {
26
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
27
+ assert.equal((error as Error).name, "BadRequestError");
28
+ return true;
29
+ },
30
+ );
31
+ });
32
+ }
33
+
34
+ test("a full page yields a token and a short page does not", () => {
35
+ assert.ok(resultList([1, 2], 2, { createdAt: 1 }).continuationToken);
36
+ assert.equal(
37
+ resultList([1], 2, { createdAt: 1 }).continuationToken,
38
+ undefined,
39
+ );
40
+ });
41
+ });
package/src/pagination.ts CHANGED
@@ -1,19 +1,31 @@
1
1
  import type { ResultList } from "@remit/data-ports";
2
+ import { BadRequestError } from "@remit/data-ports/errors";
2
3
 
3
4
  export function encodeToken(data: Record<string, unknown>): string {
4
5
  return Buffer.from(JSON.stringify(data)).toString("base64url");
5
6
  }
6
7
 
8
+ // A continuation token is opaque and server-minted: absent means "first page",
9
+ // present means "resume here". A token that does not decode is neither, so it
10
+ // is a malformed parameter. Reading it as "first page" answered the request
11
+ // with page one under a fresh token, so a client that kept paging kept
12
+ // appending the same rows with nothing signalling the failure (#136).
7
13
  export function decodeToken(
8
14
  token: string,
9
- ): Record<string, unknown> | undefined {
15
+ encoding: "base64" | "base64url" = "base64url",
16
+ ): Record<string, unknown> {
17
+ let parsed: unknown;
10
18
  try {
11
- return JSON.parse(
12
- Buffer.from(token, "base64url").toString("utf8"),
13
- ) as Record<string, unknown>;
19
+ parsed = JSON.parse(Buffer.from(token, encoding).toString("utf8"));
14
20
  } catch {
15
- return undefined;
21
+ throw new BadRequestError("Invalid continuationToken");
16
22
  }
23
+
24
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
25
+ throw new BadRequestError("Invalid continuationToken");
26
+ }
27
+
28
+ return parsed as Record<string, unknown>;
17
29
  }
18
30
 
19
31
  export function resultList<T>(
@@ -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
  }
@@ -520,4 +520,92 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
520
520
  "pages do not overlap",
521
521
  );
522
522
  });
523
+
524
+ describe("search continuation token", () => {
525
+ const acct = "acct-search-cursor";
526
+
527
+ before(async () => {
528
+ const base = Date.now();
529
+ for (let i = 0; i < 4; i++) {
530
+ await repo.create(
531
+ makeInput({
532
+ accountConfigId: acct,
533
+ subject: `cursor probe ${i}`,
534
+ sentDate: base - i,
535
+ internalDate: base - i,
536
+ }),
537
+ );
538
+ }
539
+ });
540
+
541
+ test("an absent token returns the first page", async () => {
542
+ const page = await repo.searchByMailboxWindow(
543
+ acct,
544
+ MAILBOX,
545
+ { subject: "cursor probe" },
546
+ { limit: 2, order: "desc" },
547
+ );
548
+ assert.equal(page.items.length, 2);
549
+ assert.equal(page.items[0]?.subject, "cursor probe 0");
550
+ assert.ok(page.continuationToken);
551
+ });
552
+
553
+ test("a server-minted token returns the next page", async () => {
554
+ const first = await repo.searchByMailboxWindow(
555
+ acct,
556
+ MAILBOX,
557
+ { subject: "cursor probe" },
558
+ { limit: 2, order: "desc" },
559
+ );
560
+ const second = await repo.searchByMailboxWindow(
561
+ acct,
562
+ MAILBOX,
563
+ { subject: "cursor probe" },
564
+ {
565
+ limit: 2,
566
+ order: "desc",
567
+ continuationToken: first.continuationToken,
568
+ },
569
+ );
570
+ assert.equal(second.items.length, 2);
571
+ const firstIds = new Set(first.items.map((i) => i.threadMessageId));
572
+ assert.ok(
573
+ second.items.every((i) => !firstIds.has(i.threadMessageId)),
574
+ "pages do not overlap",
575
+ );
576
+ });
577
+
578
+ for (const [label, token] of [
579
+ ["an unparseable", "not-a-cursor"],
580
+ ["a non-object", Buffer.from("123").toString("base64")],
581
+ ["an incomplete", Buffer.from('{"s":1}').toString("base64")],
582
+ ] as const) {
583
+ test(`${label} token is a validation failure`, async () => {
584
+ await assert.rejects(
585
+ () =>
586
+ repo.searchByMailboxWindow(
587
+ acct,
588
+ MAILBOX,
589
+ { subject: "cursor probe" },
590
+ { limit: 2, continuationToken: token },
591
+ ),
592
+ (error: unknown) => {
593
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
594
+ assert.equal((error as Error).name, "BadRequestError");
595
+ return true;
596
+ },
597
+ );
598
+ });
599
+ }
600
+
601
+ test("an undecodable account cursor is a validation failure", async () => {
602
+ await assert.rejects(
603
+ () => repo.listByAccount(acct, { continuationToken: "not-a-cursor" }),
604
+ (error: unknown) => {
605
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
606
+ return true;
607
+ },
608
+ );
609
+ });
610
+ });
523
611
  });
@@ -6,6 +6,7 @@ import type {
6
6
  ThreadMessageItem,
7
7
  UpdateThreadMessageInput,
8
8
  } from "@remit/data-ports";
9
+ import { BadRequestError } from "@remit/data-ports/errors";
9
10
  import {
10
11
  and,
11
12
  asc,
@@ -23,6 +24,7 @@ import shortUuid from "short-uuid";
23
24
  import { v5 as uuidv5 } from "uuid";
24
25
  import type { Db } from "../db.js";
25
26
  import { NotFoundError } from "../error.js";
27
+ import { decodeToken } from "../pagination.js";
26
28
  import { threadMessageTable } from "../schema/thread-message.js";
27
29
  import { fromMatch, subjectMatch } from "./thread-search-predicates.js";
28
30
 
@@ -64,12 +66,12 @@ function encodeDateCursor(sentDate: number, threadMessageId: string): string {
64
66
  ).toString("base64");
65
67
  }
66
68
 
67
- function decodeDateCursor(token: string): DateCursor | null {
68
- try {
69
- return JSON.parse(Buffer.from(token, "base64").toString()) as DateCursor;
70
- } catch {
71
- return null;
69
+ function decodeDateCursor(token: string): DateCursor {
70
+ const decoded = decodeToken(token, "base64");
71
+ if (typeof decoded.s !== "number" || typeof decoded.id !== "string") {
72
+ throw new BadRequestError("Invalid continuationToken");
72
73
  }
74
+ return { s: decoded.s, id: decoded.id };
73
75
  }
74
76
 
75
77
  function encodeAccountCursor(threadMessageId: string): string {
@@ -78,12 +80,12 @@ function encodeAccountCursor(threadMessageId: string): string {
78
80
  );
79
81
  }
80
82
 
81
- function decodeAccountCursor(token: string): AccountCursor | null {
82
- try {
83
- return JSON.parse(Buffer.from(token, "base64").toString()) as AccountCursor;
84
- } catch {
85
- return null;
83
+ function decodeAccountCursor(token: string): AccountCursor {
84
+ const decoded = decodeToken(token, "base64");
85
+ if (typeof decoded.id !== "string") {
86
+ throw new BadRequestError("Invalid continuationToken");
86
87
  }
88
+ return { id: decoded.id };
87
89
  }
88
90
 
89
91
  // ─── Schema ──────────────────────────────────────────────────────────────────