@remit/drizzle-service 0.0.16 → 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
|
@@ -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>(
|
|
@@ -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 ──────────────────────────────────────────────────────────────────
|