@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 +1 -1
- package/src/pagination.test.ts +41 -0
- package/src/pagination.ts +17 -5
- package/src/repos/cascade-delete.test.ts +1 -1
- package/src/repos/quarantine.test.ts +81 -1
- package/src/repos/quarantine.ts +48 -2
- package/src/repos/thread-message.sqlite.test.ts +88 -0
- package/src/repos/thread-message.ts +12 -10
package/package.json
CHANGED
|
@@ -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
|
-
|
|
15
|
+
encoding: "base64" | "base64url" = "base64url",
|
|
16
|
+
): Record<string, unknown> {
|
|
17
|
+
let parsed: unknown;
|
|
10
18
|
try {
|
|
11
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
});
|
package/src/repos/quarantine.ts
CHANGED
|
@@ -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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 ──────────────────────────────────────────────────────────────────
|