@remit/drizzle-service 0.0.16 → 0.0.18

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.16",
3
+ "version": "0.0.18",
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>(
@@ -276,4 +276,26 @@ describe("FilterRepo", () => {
276
276
  "every filter is paged exactly once",
277
277
  );
278
278
  });
279
+
280
+ describe("continuation token rejection (#172)", () => {
281
+ for (const [label, token] of [
282
+ ["an unparseable", "not-a-cursor"],
283
+ ["a bare number", Buffer.from("123").toString("base64url")],
284
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
285
+ ] as const) {
286
+ test(`${label} token is rejected as a 400`, async () => {
287
+ await assert.rejects(
288
+ () =>
289
+ repo.listPageByAccountConfig(randomId(), {
290
+ continuationToken: token,
291
+ }),
292
+ (error: unknown) => {
293
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
294
+ assert.equal((error as Error).name, "BadRequestError");
295
+ return true;
296
+ },
297
+ );
298
+ });
299
+ }
300
+ });
279
301
  });
@@ -102,4 +102,23 @@ describe("AccountConfigRepo", () => {
102
102
 
103
103
  await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
104
104
  });
105
+
106
+ describe("continuation token rejection (#172)", () => {
107
+ for (const [label, token] of [
108
+ ["an unparseable", "not-a-cursor"],
109
+ ["a bare number", Buffer.from("123").toString("base64url")],
110
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
111
+ ] as const) {
112
+ test(`${label} token is rejected as a 400`, async () => {
113
+ await assert.rejects(
114
+ () => repo.listByUser(randomId(), { continuationToken: token }),
115
+ (error: unknown) => {
116
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
117
+ assert.equal((error as Error).name, "BadRequestError");
118
+ return true;
119
+ },
120
+ );
121
+ });
122
+ }
123
+ });
105
124
  });
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
+ import { AccountExportRequestRepo } from "./i4-account-export-request.js";
5
+
6
+ describe("AccountExportRequestRepo", () => {
7
+ let db: TestDb;
8
+ let close: () => Promise<void>;
9
+ let repo: AccountExportRequestRepo;
10
+
11
+ before(async () => {
12
+ ({ db, close } = await createTestDb());
13
+ repo = new AccountExportRequestRepo(db as never);
14
+ });
15
+
16
+ after(async () => {
17
+ await close();
18
+ });
19
+
20
+ describe("continuation token rejection (#172)", () => {
21
+ for (const [label, token] of [
22
+ ["an unparseable", "not-a-cursor"],
23
+ ["a bare number", Buffer.from("123").toString("base64url")],
24
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
25
+ ] as const) {
26
+ test(`${label} token is rejected as a 400`, async () => {
27
+ await assert.rejects(
28
+ () =>
29
+ repo.listByAccountConfig(randomId(), {
30
+ continuationToken: token,
31
+ }),
32
+ (error: unknown) => {
33
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
34
+ assert.equal((error as Error).name, "BadRequestError");
35
+ return true;
36
+ },
37
+ );
38
+ });
39
+ }
40
+ });
41
+ });
@@ -220,4 +220,34 @@ describe("AccountRepo", () => {
220
220
 
221
221
  await repo.deleteMany(created);
222
222
  });
223
+
224
+ describe("continuation token rejection (#172)", () => {
225
+ for (const [label, token] of [
226
+ ["an unparseable", "not-a-cursor"],
227
+ ["a bare number", Buffer.from("123").toString("base64url")],
228
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
229
+ ] as const) {
230
+ test(`list() rejects ${label} token as a 400`, async () => {
231
+ await assert.rejects(
232
+ () => repo.list(randomId(), { continuationToken: token }),
233
+ (error: unknown) => {
234
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
235
+ assert.equal((error as Error).name, "BadRequestError");
236
+ return true;
237
+ },
238
+ );
239
+ });
240
+
241
+ test(`listAllAccountsPage() rejects ${label} cursor as a 400`, async () => {
242
+ await assert.rejects(
243
+ () => repo.listAllAccountsPage({ cursor: token }),
244
+ (error: unknown) => {
245
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
246
+ assert.equal((error as Error).name, "BadRequestError");
247
+ return true;
248
+ },
249
+ );
250
+ });
251
+ }
252
+ });
223
253
  });
@@ -406,4 +406,27 @@ describe("AddressRepo", () => {
406
406
 
407
407
  await repo.deleteAddress(configB, b.addressId);
408
408
  });
409
+
410
+ describe("continuation token rejection (#172)", () => {
411
+ for (const [label, token] of [
412
+ ["an unparseable", "not-a-cursor"],
413
+ ["a bare number", Buffer.from("123").toString("base64url")],
414
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
415
+ ] as const) {
416
+ test(`${label} cursor is rejected as a 400`, async () => {
417
+ await assert.rejects(
418
+ () =>
419
+ repo.listByAccountConfig({
420
+ accountConfigId: randomId(),
421
+ cursor: token,
422
+ }),
423
+ (error: unknown) => {
424
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
425
+ assert.equal((error as Error).name, "BadRequestError");
426
+ return true;
427
+ },
428
+ );
429
+ });
430
+ }
431
+ });
409
432
  });
@@ -208,4 +208,23 @@ describe("MailboxRepo", () => {
208
208
 
209
209
  await repo.delete(accountB, b.mailboxId);
210
210
  });
211
+
212
+ describe("continuation token rejection (#172)", () => {
213
+ for (const [label, token] of [
214
+ ["an unparseable", "not-a-cursor"],
215
+ ["a bare number", Buffer.from("123").toString("base64url")],
216
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
217
+ ] as const) {
218
+ test(`${label} token is rejected as a 400`, async () => {
219
+ await assert.rejects(
220
+ () => repo.listByAccount(randomId(), { continuationToken: token }),
221
+ (error: unknown) => {
222
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
223
+ assert.equal((error as Error).name, "BadRequestError");
224
+ return true;
225
+ },
226
+ );
227
+ });
228
+ }
229
+ });
211
230
  });
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
+ import { OrganizeJobRequestRepo } from "./i4-organize-job-request.js";
5
+
6
+ describe("OrganizeJobRequestRepo", () => {
7
+ let db: TestDb;
8
+ let close: () => Promise<void>;
9
+ let repo: OrganizeJobRequestRepo;
10
+
11
+ before(async () => {
12
+ ({ db, close } = await createTestDb());
13
+ repo = new OrganizeJobRequestRepo(db as never);
14
+ });
15
+
16
+ after(async () => {
17
+ await close();
18
+ });
19
+
20
+ describe("continuation token rejection (#172)", () => {
21
+ for (const [label, token] of [
22
+ ["an unparseable", "not-a-cursor"],
23
+ ["a bare number", Buffer.from("123").toString("base64url")],
24
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
25
+ ] as const) {
26
+ test(`${label} token is rejected as a 400`, async () => {
27
+ await assert.rejects(
28
+ () =>
29
+ repo.listByAccountConfig(randomId(), {
30
+ continuationToken: token,
31
+ }),
32
+ (error: unknown) => {
33
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
34
+ assert.equal((error as Error).name, "BadRequestError");
35
+ return true;
36
+ },
37
+ );
38
+ });
39
+ }
40
+ });
41
+ });
@@ -295,4 +295,23 @@ describe("OutboxMessageRepo", () => {
295
295
 
296
296
  await repo.delete(accountConfigId, msg.outboxMessageId);
297
297
  });
298
+
299
+ describe("continuation token rejection (#172)", () => {
300
+ for (const [label, token] of [
301
+ ["an unparseable", "not-a-cursor"],
302
+ ["a bare number", Buffer.from("123").toString("base64url")],
303
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
304
+ ] as const) {
305
+ test(`${label} token is rejected as a 400`, async () => {
306
+ await assert.rejects(
307
+ () => repo.listByAccount(randomId(), { continuationToken: token }),
308
+ (error: unknown) => {
309
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
310
+ assert.equal((error as Error).name, "BadRequestError");
311
+ return true;
312
+ },
313
+ );
314
+ });
315
+ }
316
+ });
298
317
  });
@@ -485,4 +485,26 @@ describe("DrizzleMessageRepository", () => {
485
485
  await messageRepo.deleteMany([]);
486
486
  });
487
487
  });
488
+
489
+ describe("continuation token rejection (#172)", () => {
490
+ for (const [label, token] of [
491
+ ["an unparseable", "not-a-cursor"],
492
+ ["a bare number", Buffer.from("123").toString("base64url")],
493
+ ["a JSON array", Buffer.from("[1,2]").toString("base64url")],
494
+ ] as const) {
495
+ test(`${label} token is rejected as a 400`, async () => {
496
+ await assert.rejects(
497
+ () =>
498
+ messageRepo.listByMailbox("mailbox-172", {
499
+ continuationToken: token,
500
+ }),
501
+ (error: unknown) => {
502
+ assert.equal((error as { statusCode?: number }).statusCode, 400);
503
+ assert.equal((error as Error).name, "BadRequestError");
504
+ return true;
505
+ },
506
+ );
507
+ });
508
+ }
509
+ });
488
510
  });
@@ -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 ──────────────────────────────────────────────────────────────────