@remit/drizzle-service 0.0.1

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.
Files changed (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. package/tsconfig.json +8 -0
@@ -0,0 +1,141 @@
1
+ import type {
2
+ AccountExportRequestItem,
3
+ CreateAccountExportRequestInput,
4
+ IAccountExportRequestRepository,
5
+ ResultList,
6
+ UpdateAccountExportRequestInput,
7
+ } from "@remit/data-ports";
8
+ import { eq } from "drizzle-orm";
9
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
10
+ import { NotFoundError } from "../error.js";
11
+ import { randomId } from "../id.js";
12
+ import { decodeToken, resultList } from "../pagination.js";
13
+ import { accountExportRequestTable } from "../schema/i4-account-export-request.js";
14
+
15
+ type DB = NodePgDatabase<Record<string, unknown>>;
16
+
17
+ function rowToItem(
18
+ row: typeof accountExportRequestTable.$inferSelect,
19
+ ): AccountExportRequestItem {
20
+ return {
21
+ accountExportRequestId: row.accountExportRequestId,
22
+ accountConfigId: row.accountConfigId,
23
+ userId: row.userId,
24
+ state: row.state as AccountExportRequestItem["state"],
25
+ createdAt: row.createdAt,
26
+ updatedAt: row.updatedAt,
27
+ expiresAt: row.expiresAt ?? undefined,
28
+ objectKey: row.objectKey ?? undefined,
29
+ downloadUrl: row.downloadUrl ?? undefined,
30
+ errorMessage: row.errorMessage ?? undefined,
31
+ };
32
+ }
33
+
34
+ export class AccountExportRequestRepo
35
+ implements IAccountExportRequestRepository
36
+ {
37
+ constructor(private db: DB) {}
38
+
39
+ async create(
40
+ input: CreateAccountExportRequestInput,
41
+ ): Promise<AccountExportRequestItem> {
42
+ const now = Date.now();
43
+ const [row] = await this.db
44
+ .insert(accountExportRequestTable)
45
+ .values({
46
+ accountExportRequestId: randomId(),
47
+ accountConfigId: input.accountConfigId,
48
+ userId: input.userId,
49
+ state: input.state ?? "Pending",
50
+ expiresAt: input.expiresAt,
51
+ objectKey: input.objectKey,
52
+ downloadUrl: input.downloadUrl,
53
+ errorMessage: input.errorMessage,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ })
57
+ .returning();
58
+ return rowToItem(row);
59
+ }
60
+
61
+ async get(accountExportRequestId: string): Promise<AccountExportRequestItem> {
62
+ const [row] = await this.db
63
+ .select()
64
+ .from(accountExportRequestTable)
65
+ .where(
66
+ eq(
67
+ accountExportRequestTable.accountExportRequestId,
68
+ accountExportRequestId,
69
+ ),
70
+ );
71
+ if (!row)
72
+ throw new NotFoundError(
73
+ `AccountExportRequest not found: ${accountExportRequestId}`,
74
+ );
75
+ return rowToItem(row);
76
+ }
77
+
78
+ async update(
79
+ accountExportRequestId: string,
80
+ input: UpdateAccountExportRequestInput,
81
+ ): Promise<AccountExportRequestItem> {
82
+ const now = Date.now();
83
+ const updates: Partial<typeof accountExportRequestTable.$inferInsert> = {
84
+ updatedAt: now,
85
+ };
86
+ if (input.state !== undefined) updates.state = input.state;
87
+ if (input.expiresAt !== undefined) updates.expiresAt = input.expiresAt;
88
+ if (input.errorMessage !== undefined)
89
+ updates.errorMessage = input.errorMessage;
90
+ if (input.objectKey !== undefined) updates.objectKey = input.objectKey;
91
+
92
+ const [row] = await this.db
93
+ .update(accountExportRequestTable)
94
+ .set(updates)
95
+ .where(
96
+ eq(
97
+ accountExportRequestTable.accountExportRequestId,
98
+ accountExportRequestId,
99
+ ),
100
+ )
101
+ .returning();
102
+ if (!row)
103
+ throw new NotFoundError(
104
+ `AccountExportRequest not found: ${accountExportRequestId}`,
105
+ );
106
+ return rowToItem(row);
107
+ }
108
+
109
+ async listByAccountConfig(
110
+ accountConfigId: string,
111
+ options?: { limit?: number; continuationToken?: string },
112
+ ): Promise<ResultList<AccountExportRequestItem>> {
113
+ const limit = options?.limit ?? 100;
114
+ let _afterCreatedAt: number | undefined;
115
+
116
+ if (options?.continuationToken) {
117
+ const decoded = decodeToken(options.continuationToken);
118
+ _afterCreatedAt = decoded?.createdAt as number | undefined;
119
+ }
120
+
121
+ const rows = await this.db
122
+ .select()
123
+ .from(accountExportRequestTable)
124
+ .where(eq(accountExportRequestTable.accountConfigId, accountConfigId))
125
+ .limit(limit + 1);
126
+
127
+ const hasMore = rows.length > limit;
128
+ const items = rows.slice(0, limit).map(rowToItem);
129
+ const lastItem = items[items.length - 1];
130
+ return resultList(
131
+ items,
132
+ limit,
133
+ hasMore && lastItem
134
+ ? {
135
+ createdAt: lastItem.createdAt,
136
+ accountExportRequestId: lastItem.accountExportRequestId,
137
+ }
138
+ : undefined,
139
+ );
140
+ }
141
+ }
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
+ import { AccountSettingRepo } from "./i4-account-setting.js";
5
+
6
+ describe("AccountSettingRepo", () => {
7
+ let db: TestDb;
8
+ let close: () => Promise<void>;
9
+ let repo: AccountSettingRepo;
10
+
11
+ before(async () => {
12
+ ({ db, close } = await createTestDb());
13
+ repo = new AccountSettingRepo(db as never);
14
+ });
15
+
16
+ after(async () => {
17
+ await close();
18
+ });
19
+
20
+ test("upsert and get", async () => {
21
+ const accountConfigId = randomId();
22
+ await repo.upsert({
23
+ accountConfigId,
24
+ name: "Theme",
25
+ value: { kind: "String", value: "dark" },
26
+ });
27
+
28
+ const setting = await repo.get(accountConfigId, "Theme");
29
+ assert.ok(setting);
30
+ assert.equal(setting.name, "Theme");
31
+ assert.deepEqual(setting.value, { kind: "String", value: "dark" });
32
+
33
+ await repo.delete(accountConfigId, "Theme");
34
+ });
35
+
36
+ test("upsert is idempotent (overwrites)", async () => {
37
+ const accountConfigId = randomId();
38
+ await repo.upsert({
39
+ accountConfigId,
40
+ name: "Density",
41
+ value: { kind: "String", value: "compact" },
42
+ });
43
+ await repo.upsert({
44
+ accountConfigId,
45
+ name: "Density",
46
+ value: { kind: "String", value: "comfortable" },
47
+ });
48
+
49
+ const setting = await repo.get(accountConfigId, "Density");
50
+ assert.deepEqual(setting?.value, { kind: "String", value: "comfortable" });
51
+
52
+ await repo.delete(accountConfigId, "Density");
53
+ });
54
+
55
+ test("get returns null when setting absent", async () => {
56
+ const setting = await repo.get(randomId(), "Theme");
57
+ assert.equal(setting, null);
58
+ });
59
+
60
+ test("listByAccountConfig returns all settings ordered by name", async () => {
61
+ const accountConfigId = randomId();
62
+ await repo.upsert({
63
+ accountConfigId,
64
+ name: "Theme",
65
+ value: { kind: "String", value: "dark" },
66
+ });
67
+ await repo.upsert({
68
+ accountConfigId,
69
+ name: "Density",
70
+ value: { kind: "String", value: "compact" },
71
+ });
72
+
73
+ const settings = await repo.listByAccountConfig(accountConfigId);
74
+ assert.equal(settings.length, 2);
75
+ assert.equal(settings[0].name, "Density");
76
+ assert.equal(settings[1].name, "Theme");
77
+
78
+ await repo.delete(accountConfigId, "Theme");
79
+ await repo.delete(accountConfigId, "Density");
80
+ });
81
+
82
+ test("delete removes the setting", async () => {
83
+ const accountConfigId = randomId();
84
+ await repo.upsert({
85
+ accountConfigId,
86
+ name: "Theme",
87
+ value: { kind: "Boolean", value: true },
88
+ });
89
+ await repo.delete(accountConfigId, "Theme");
90
+
91
+ const setting = await repo.get(accountConfigId, "Theme");
92
+ assert.equal(setting, null);
93
+ });
94
+ });
@@ -0,0 +1,92 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ AccountSettingItem,
4
+ AccountSettingValue,
5
+ IAccountSettingRepository,
6
+ UpsertAccountSettingInput,
7
+ } from "@remit/data-ports";
8
+ import { eq } from "drizzle-orm";
9
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
10
+ import { accountSettingTable } from "../schema/i4-account-setting.js";
11
+
12
+ type DB = NodePgDatabase<Record<string, unknown>>;
13
+
14
+ // The PG store derives its own deterministic id from the composite key. It is
15
+ // stable within this backend but not byte-identical to the DDB base36 UUIDv5
16
+ // id — the two stores key the same row differently by design.
17
+ function deriveId(accountConfigId: string, name: string): string {
18
+ return createHash("sha256")
19
+ .update(`accountSetting:${accountConfigId}:${name}`)
20
+ .digest("hex");
21
+ }
22
+
23
+ function rowToAccountSetting(
24
+ row: typeof accountSettingTable.$inferSelect,
25
+ ): AccountSettingItem {
26
+ return {
27
+ accountSettingId: row.accountSettingId,
28
+ accountConfigId: row.accountConfigId,
29
+ name: row.name,
30
+ value: row.value as AccountSettingValue,
31
+ createdAt: row.createdAt,
32
+ updatedAt: row.updatedAt,
33
+ };
34
+ }
35
+
36
+ export class AccountSettingRepo implements IAccountSettingRepository {
37
+ constructor(private db: DB) {}
38
+
39
+ async get(
40
+ accountConfigId: string,
41
+ name: string,
42
+ ): Promise<AccountSettingItem | null> {
43
+ const accountSettingId = deriveId(accountConfigId, name);
44
+ const [row] = await this.db
45
+ .select()
46
+ .from(accountSettingTable)
47
+ .where(eq(accountSettingTable.accountSettingId, accountSettingId));
48
+ return row ? rowToAccountSetting(row) : null;
49
+ }
50
+
51
+ async listByAccountConfig(
52
+ accountConfigId: string,
53
+ ): Promise<AccountSettingItem[]> {
54
+ const rows = await this.db
55
+ .select()
56
+ .from(accountSettingTable)
57
+ .where(eq(accountSettingTable.accountConfigId, accountConfigId))
58
+ .orderBy(accountSettingTable.name);
59
+ return rows.map(rowToAccountSetting);
60
+ }
61
+
62
+ async upsert(input: UpsertAccountSettingInput): Promise<AccountSettingItem> {
63
+ const now = Date.now();
64
+ const accountSettingId = deriveId(input.accountConfigId, input.name);
65
+ const [row] = await this.db
66
+ .insert(accountSettingTable)
67
+ .values({
68
+ accountSettingId,
69
+ accountConfigId: input.accountConfigId,
70
+ name: input.name,
71
+ value: input.value as never,
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ })
75
+ .onConflictDoUpdate({
76
+ target: accountSettingTable.accountSettingId,
77
+ set: {
78
+ value: input.value as never,
79
+ updatedAt: now,
80
+ },
81
+ })
82
+ .returning();
83
+ return rowToAccountSetting(row);
84
+ }
85
+
86
+ async delete(accountConfigId: string, name: string): Promise<void> {
87
+ const accountSettingId = deriveId(accountConfigId, name);
88
+ await this.db
89
+ .delete(accountSettingTable)
90
+ .where(eq(accountSettingTable.accountSettingId, accountSettingId));
91
+ }
92
+ }
@@ -0,0 +1,223 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
4
+ import { AccountRepo } from "./i4-account.js";
5
+ import { MailboxRepo } from "./i4-mailbox.js";
6
+
7
+ function makeAccountInput(accountConfigId: string) {
8
+ return {
9
+ accountConfigId,
10
+ username: "testuser",
11
+ email: "test@example.com",
12
+ isActive: true,
13
+ imapHost: "imap.example.com",
14
+ imapPort: 993,
15
+ imapTls: true,
16
+ imapStartTls: false,
17
+ connectionState: "not_authenticated" as const,
18
+ };
19
+ }
20
+
21
+ describe("AccountRepo", () => {
22
+ let db: TestDb;
23
+ let close: () => Promise<void>;
24
+ let repo: AccountRepo;
25
+
26
+ before(async () => {
27
+ ({ db, close } = await createTestDb());
28
+ repo = new AccountRepo(db as never);
29
+ });
30
+
31
+ after(async () => {
32
+ await close();
33
+ });
34
+
35
+ test("create and get", async () => {
36
+ const accountConfigId = randomId();
37
+ const account = await repo.create(makeAccountInput(accountConfigId));
38
+
39
+ assert.ok(account.accountId, "accountId generated");
40
+ assert.equal(account.accountConfigId, accountConfigId);
41
+ assert.equal(account.authType, "password");
42
+
43
+ const fetched = await repo.get(account.accountId);
44
+ assert.equal(fetched.accountId, account.accountId);
45
+
46
+ await repo.delete(account.accountId);
47
+ });
48
+
49
+ test("batchGet: WHERE id = ANY($1)", async () => {
50
+ const accountConfigId = randomId();
51
+ const a1 = await repo.create(makeAccountInput(accountConfigId));
52
+ const a2 = await repo.create(makeAccountInput(accountConfigId));
53
+
54
+ const results = await repo.get([a1.accountId, a2.accountId]);
55
+ assert.equal(results.length, 2);
56
+
57
+ await repo.deleteMany([a1.accountId, a2.accountId]);
58
+ });
59
+
60
+ test("batchGet with empty array returns []", async () => {
61
+ const results = await repo.get([]);
62
+ assert.deepEqual(results, []);
63
+ });
64
+
65
+ test("get throws NotFoundError for missing account", async () => {
66
+ await assert.rejects(repo.get(randomId()), /not found/i);
67
+ });
68
+
69
+ test("markAuthenticated clears lastError and sets connectionState", async () => {
70
+ const created = await repo.create({
71
+ ...makeAccountInput(randomId()),
72
+ lastError: "previous failure",
73
+ });
74
+
75
+ const updated = await repo.markAuthenticated(created.accountId);
76
+
77
+ assert.equal(updated.connectionState, "authenticated");
78
+ assert.equal(updated.lastError, undefined);
79
+ assert.ok((updated.lastConnectedAt ?? 0) > 0);
80
+
81
+ await repo.delete(created.accountId);
82
+ });
83
+
84
+ test("incrementMailboxSynced atomically increments counter", async () => {
85
+ const account = await repo.create({
86
+ ...makeAccountInput(randomId()),
87
+ syncPhase: "syncing_others",
88
+ mailboxCountTotal: 3,
89
+ mailboxCountSynced: 0,
90
+ });
91
+
92
+ const updated = await repo.incrementMailboxSynced(account.accountId);
93
+ assert.equal(updated.mailboxCountSynced, 1);
94
+ assert.equal(updated.syncPhase, "syncing_others");
95
+
96
+ await repo.delete(account.accountId);
97
+ });
98
+
99
+ test("incrementMailboxSynced transitions to complete when all done", async () => {
100
+ const account = await repo.create({
101
+ ...makeAccountInput(randomId()),
102
+ syncPhase: "syncing_others",
103
+ mailboxCountTotal: 2,
104
+ mailboxCountSynced: 1,
105
+ });
106
+
107
+ const updated = await repo.incrementMailboxSynced(account.accountId);
108
+ assert.equal(updated.mailboxCountSynced, 2);
109
+ assert.equal(updated.syncPhase, "complete");
110
+
111
+ await repo.delete(account.accountId);
112
+ });
113
+
114
+ test("incrementMailboxSynced clamps to total on duplicate completions", async () => {
115
+ const account = await repo.create({
116
+ ...makeAccountInput(randomId()),
117
+ syncPhase: "syncing_others",
118
+ mailboxCountTotal: 2,
119
+ mailboxCountSynced: 2,
120
+ });
121
+
122
+ const updated = await repo.incrementMailboxSynced(account.accountId);
123
+ assert.equal(updated.mailboxCountSynced, 2, "should clamp to total");
124
+ assert.equal(updated.syncPhase, "complete");
125
+
126
+ await repo.delete(account.accountId);
127
+ });
128
+
129
+ test("list paginates without dupes, gaps, or non-termination", async () => {
130
+ const accountConfigId = randomId();
131
+ const created: string[] = [];
132
+ for (let i = 0; i < 5; i++) {
133
+ const account = await repo.create(makeAccountInput(accountConfigId));
134
+ created.push(account.accountId);
135
+ }
136
+
137
+ const seen: string[] = [];
138
+ let continuationToken: string | undefined;
139
+ let pages = 0;
140
+ do {
141
+ const page = await repo.list(accountConfigId, {
142
+ limit: 2,
143
+ continuationToken,
144
+ });
145
+ seen.push(...page.items.map((a) => a.accountId));
146
+ continuationToken = page.continuationToken;
147
+ pages++;
148
+ assert.ok(pages < 10, "pagination must terminate");
149
+ } while (continuationToken);
150
+
151
+ assert.equal(seen.length, 5, "every row returned exactly once");
152
+ assert.equal(new Set(seen).size, 5, "no duplicates across pages");
153
+ assert.deepEqual([...seen].sort(), [...created].sort(), "no gaps");
154
+
155
+ await repo.deleteMany(created);
156
+ });
157
+
158
+ test("describe assembles account + mailbox collection", async () => {
159
+ const accountConfigId = randomId();
160
+ const account = await repo.create(makeAccountInput(accountConfigId));
161
+
162
+ const mailboxRepo = new MailboxRepo(db as never);
163
+ await mailboxRepo.create({
164
+ accountId: account.accountId,
165
+ namespaceType: "personal",
166
+ namespacePrefix: "",
167
+ hierarchyDelimiter: "/",
168
+ fullPath: "INBOX",
169
+ uidValidity: 1,
170
+ uidNext: 1,
171
+ highestModseq: 0,
172
+ messageCount: 0,
173
+ unseenCount: 0,
174
+ deletedCount: 0,
175
+ totalSize: 0,
176
+ lastSyncUid: 0,
177
+ highWaterMarkUid: 0,
178
+ lastMessageSyncAt: Date.now(),
179
+ });
180
+
181
+ const desc = await repo.describe(account.accountId);
182
+ assert.equal(desc.account.length, 1);
183
+ assert.equal(desc.mailbox.length, 1);
184
+ assert.equal(desc.mailbox[0].fullPath, "INBOX");
185
+
186
+ await repo.delete(account.accountId);
187
+ });
188
+
189
+ test("listAllAccountsPage paginates without dupes, gaps, or non-termination", async () => {
190
+ const accountConfigId = randomId();
191
+ const created: string[] = [];
192
+ for (let i = 0; i < 5; i++) {
193
+ const account = await repo.create(makeAccountInput(accountConfigId));
194
+ created.push(account.accountId);
195
+ }
196
+
197
+ const seen: string[] = [];
198
+ let cursor: string | undefined;
199
+ let pages = 0;
200
+ do {
201
+ const page = await repo.listAllAccountsPage({ limit: 2, cursor });
202
+ seen.push(...page.items.map((a) => a.accountId));
203
+ cursor = page.cursor ?? undefined;
204
+ pages++;
205
+ assert.ok(pages < 50, "pagination must terminate");
206
+ } while (cursor);
207
+
208
+ for (const accountId of created) {
209
+ assert.ok(
210
+ seen.includes(accountId),
211
+ `created account ${accountId} must appear in a page`,
212
+ );
213
+ }
214
+ const seenCreated = seen.filter((id) => created.includes(id));
215
+ assert.equal(
216
+ new Set(seenCreated).size,
217
+ seenCreated.length,
218
+ "no duplicates across pages",
219
+ );
220
+
221
+ await repo.deleteMany(created);
222
+ });
223
+ });