@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,188 @@
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 { MailboxRepo } from "./i4-mailbox.js";
5
+
6
+ function makeMailboxInput(accountId: string, fullPath = "INBOX") {
7
+ return {
8
+ accountId,
9
+ namespaceType: "personal" as const,
10
+ namespacePrefix: "",
11
+ hierarchyDelimiter: "/",
12
+ fullPath,
13
+ uidValidity: 1,
14
+ uidNext: 1,
15
+ highestModseq: 0,
16
+ messageCount: 0,
17
+ unseenCount: 5,
18
+ deletedCount: 0,
19
+ totalSize: 0,
20
+ lastSyncUid: 0,
21
+ highWaterMarkUid: 0,
22
+ lastMessageSyncAt: Date.now(),
23
+ };
24
+ }
25
+
26
+ describe("MailboxRepo", () => {
27
+ let db: TestDb;
28
+ let close: () => Promise<void>;
29
+ let repo: MailboxRepo;
30
+
31
+ before(async () => {
32
+ ({ db, close } = await createTestDb());
33
+ repo = new MailboxRepo(db as never);
34
+ });
35
+
36
+ after(async () => {
37
+ await close();
38
+ });
39
+
40
+ test("create and get", async () => {
41
+ const accountId = randomId();
42
+ const mailbox = await repo.create(makeMailboxInput(accountId));
43
+ assert.ok(mailbox.mailboxId);
44
+ assert.equal(mailbox.fullPath, "INBOX");
45
+
46
+ const fetched = await repo.get(accountId, mailbox.mailboxId);
47
+ assert.equal(fetched.mailboxId, mailbox.mailboxId);
48
+
49
+ await repo.delete(accountId, mailbox.mailboxId);
50
+ });
51
+
52
+ test("batchGet: WHERE id = ANY($1)", async () => {
53
+ const accountId = randomId();
54
+ const m1 = await repo.create(makeMailboxInput(accountId, "INBOX"));
55
+ const m2 = await repo.create(makeMailboxInput(accountId, "Sent"));
56
+
57
+ const results = await repo.get(accountId, [m1.mailboxId, m2.mailboxId]);
58
+ assert.equal(results.length, 2);
59
+
60
+ await repo.deleteMany(accountId, [m1.mailboxId, m2.mailboxId]);
61
+ });
62
+
63
+ test("batchGet empty array returns []", async () => {
64
+ const results = await repo.get(randomId(), []);
65
+ assert.deepEqual(results, []);
66
+ });
67
+
68
+ test("findByPath finds existing mailbox", async () => {
69
+ const accountId = randomId();
70
+ await repo.create(makeMailboxInput(accountId, "Work/Projects"));
71
+
72
+ const found = await repo.findByPath(accountId, "Work/Projects");
73
+ assert.ok(found);
74
+ assert.equal(found.fullPath, "Work/Projects");
75
+ });
76
+
77
+ test("findByPath returns null when not found", async () => {
78
+ const result = await repo.findByPath(randomId(), "INBOX");
79
+ assert.equal(result, null);
80
+ });
81
+
82
+ test("getOrCreateByPath creates when missing", async () => {
83
+ const accountId = randomId();
84
+ const mailbox = await repo.getOrCreateByPath(
85
+ accountId,
86
+ "Drafts",
87
+ makeMailboxInput(accountId, "Drafts"),
88
+ );
89
+ assert.equal(mailbox.fullPath, "Drafts");
90
+
91
+ const again = await repo.getOrCreateByPath(
92
+ accountId,
93
+ "Drafts",
94
+ makeMailboxInput(accountId, "Drafts"),
95
+ );
96
+ assert.equal(again.mailboxId, mailbox.mailboxId, "idempotent");
97
+ });
98
+
99
+ test("findByPathPrefix finds children", async () => {
100
+ const accountId = randomId();
101
+ await repo.create(makeMailboxInput(accountId, "Work"));
102
+ await repo.create(makeMailboxInput(accountId, "Work/Projects"));
103
+ await repo.create(makeMailboxInput(accountId, "Work/Projects/Alpha"));
104
+
105
+ const children = await repo.findByPathPrefix(accountId, "Work");
106
+ assert.ok(children.some((m) => m.fullPath === "Work/Projects"));
107
+ assert.ok(children.some((m) => m.fullPath === "Work/Projects/Alpha"));
108
+ assert.equal(
109
+ children.find((m) => m.fullPath === "Work"),
110
+ undefined,
111
+ "prefix itself not included",
112
+ );
113
+ });
114
+
115
+ test("renameChildPaths updates all children", async () => {
116
+ const accountId = randomId();
117
+ await repo.create(makeMailboxInput(accountId, "OldName/Sub1"));
118
+ await repo.create(makeMailboxInput(accountId, "OldName/Sub2"));
119
+
120
+ await repo.renameChildPaths(accountId, "OldName", "NewName");
121
+
122
+ const sub1 = await repo.findByPath(accountId, "NewName/Sub1");
123
+ const sub2 = await repo.findByPath(accountId, "NewName/Sub2");
124
+ assert.ok(sub1, "Sub1 renamed");
125
+ assert.ok(sub2, "Sub2 renamed");
126
+ });
127
+
128
+ test("cross-tenant: get refuses a foreign account", async () => {
129
+ const accountId = randomId();
130
+ const other = randomId();
131
+ const mailbox = await repo.create(makeMailboxInput(accountId));
132
+
133
+ await assert.rejects(
134
+ () => repo.get(other, mailbox.mailboxId),
135
+ /Mailbox not found/,
136
+ );
137
+ assert.deepEqual(await repo.get(other, [mailbox.mailboxId]), []);
138
+ const owned = await repo.get(accountId, [mailbox.mailboxId]);
139
+ assert.equal(owned.length, 1);
140
+
141
+ await repo.delete(accountId, mailbox.mailboxId);
142
+ });
143
+
144
+ test("cross-tenant: update refuses a foreign account and leaves the row unchanged", async () => {
145
+ const accountId = randomId();
146
+ const other = randomId();
147
+ const mailbox = await repo.create(makeMailboxInput(accountId));
148
+
149
+ await assert.rejects(
150
+ () => repo.update(other, mailbox.mailboxId, { messageCount: 99 }),
151
+ /Mailbox not found/,
152
+ );
153
+ const still = await repo.get(accountId, mailbox.mailboxId);
154
+ assert.equal(still.messageCount, 0);
155
+
156
+ await repo.delete(accountId, mailbox.mailboxId);
157
+ });
158
+
159
+ test("cross-tenant: delete is a no-op for a foreign account", async () => {
160
+ const accountId = randomId();
161
+ const other = randomId();
162
+ const mailbox = await repo.create(makeMailboxInput(accountId));
163
+
164
+ await repo.delete(other, mailbox.mailboxId);
165
+ const still = await repo.get(accountId, mailbox.mailboxId);
166
+ assert.equal(still.mailboxId, mailbox.mailboxId);
167
+
168
+ await repo.delete(accountId, mailbox.mailboxId);
169
+ });
170
+
171
+ test("cross-tenant: deleteMany only removes ids owned by the tenant", async () => {
172
+ const accountA = randomId();
173
+ const accountB = randomId();
174
+ const a = await repo.create(makeMailboxInput(accountA));
175
+ const b = await repo.create(makeMailboxInput(accountB));
176
+
177
+ await repo.deleteMany(accountA, [a.mailboxId, b.mailboxId]);
178
+
179
+ await assert.rejects(
180
+ () => repo.get(accountA, a.mailboxId),
181
+ /Mailbox not found/,
182
+ );
183
+ const survived = await repo.get(accountB, b.mailboxId);
184
+ assert.equal(survived.mailboxId, b.mailboxId);
185
+
186
+ await repo.delete(accountB, b.mailboxId);
187
+ });
188
+ });
@@ -0,0 +1,347 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ CreateMailboxInput,
4
+ IMailboxRepository,
5
+ MailboxItem,
6
+ ResultList,
7
+ UpdateMailboxInput,
8
+ } from "@remit/data-ports";
9
+ import { MailboxCursorState } from "@remit/domain-enums";
10
+ import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
11
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
12
+ import shortUuid from "short-uuid";
13
+ import { NotFoundError } from "../error.js";
14
+ import { decodeToken, resultList } from "../pagination.js";
15
+ import { mailboxTable } from "../schema/i4-mailbox.js";
16
+
17
+ const base36Translator = shortUuid.createTranslator(
18
+ shortUuid.constants.uuid25Base36,
19
+ );
20
+ const generateMailboxId = () => base36Translator.fromUUID(randomUUID());
21
+
22
+ type DB = NodePgDatabase<Record<string, unknown>>;
23
+
24
+ export function rowToMailbox(
25
+ row: typeof mailboxTable.$inferSelect,
26
+ ): MailboxItem {
27
+ return {
28
+ mailboxId: row.mailboxId,
29
+ accountId: row.accountId,
30
+ namespaceType: row.namespaceType as MailboxItem["namespaceType"],
31
+ namespacePrefix: row.namespacePrefix,
32
+ hierarchyDelimiter: row.hierarchyDelimiter,
33
+ fullPath: row.fullPath,
34
+ uidValidity: row.uidValidity,
35
+ uidNext: row.uidNext,
36
+ highestModseq: row.highestModseq,
37
+ messageCount: row.messageCount,
38
+ unseenCount: row.unseenCount,
39
+ deletedCount: row.deletedCount,
40
+ totalSize: row.totalSize,
41
+ lastSyncUid: row.lastSyncUid,
42
+ highWaterMarkUid: row.highWaterMarkUid,
43
+ lastMessageSyncAt: row.lastMessageSyncAt,
44
+ initialSyncCompletedAt: row.initialSyncCompletedAt ?? undefined,
45
+ parentMailboxId: row.parentMailboxId,
46
+ syncStatus: (row.syncStatus as MailboxItem["syncStatus"]) ?? undefined,
47
+ cursorState: (row.cursorState as MailboxItem["cursorState"]) ?? undefined,
48
+ oldPath: row.oldPath ?? undefined,
49
+ specialUse: (row.specialUse as MailboxItem["specialUse"]) ?? undefined,
50
+ createdAt: row.createdAt,
51
+ updatedAt: row.updatedAt,
52
+ };
53
+ }
54
+
55
+ export class MailboxRepo implements IMailboxRepository {
56
+ constructor(private db: DB) {}
57
+
58
+ async create(input: CreateMailboxInput): Promise<MailboxItem> {
59
+ const now = Date.now();
60
+ const [row] = await this.db
61
+ .insert(mailboxTable)
62
+ .values({
63
+ mailboxId: generateMailboxId(),
64
+ accountId: input.accountId,
65
+ namespaceType: input.namespaceType ?? "personal",
66
+ namespacePrefix: input.namespacePrefix,
67
+ hierarchyDelimiter: input.hierarchyDelimiter,
68
+ fullPath: input.fullPath,
69
+ uidValidity: input.uidValidity,
70
+ uidNext: input.uidNext,
71
+ highestModseq: input.highestModseq,
72
+ messageCount: input.messageCount,
73
+ unseenCount: input.unseenCount,
74
+ deletedCount: input.deletedCount,
75
+ totalSize: input.totalSize,
76
+ lastSyncUid: input.lastSyncUid,
77
+ highWaterMarkUid: input.highWaterMarkUid,
78
+ lastMessageSyncAt: input.lastMessageSyncAt,
79
+ initialSyncCompletedAt: input.initialSyncCompletedAt,
80
+ parentMailboxId: input.parentMailboxId ?? "",
81
+ syncStatus: input.syncStatus,
82
+ cursorState: input.cursorState ?? MailboxCursorState.normal,
83
+ oldPath: input.oldPath,
84
+ specialUse: input.specialUse ?? null,
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ })
88
+ .returning();
89
+ return rowToMailbox(row);
90
+ }
91
+
92
+ async get(accountId: string, mailboxId: string): Promise<MailboxItem>;
93
+ async get(accountId: string, mailboxIds: string[]): Promise<MailboxItem[]>;
94
+ async get(
95
+ accountId: string,
96
+ mailboxId: string | string[],
97
+ ): Promise<MailboxItem | MailboxItem[]> {
98
+ if (Array.isArray(mailboxId)) {
99
+ if (mailboxId.length === 0) return [];
100
+ const rows = await this.db
101
+ .select()
102
+ .from(mailboxTable)
103
+ .where(
104
+ and(
105
+ eq(mailboxTable.accountId, accountId),
106
+ inArray(mailboxTable.mailboxId, mailboxId),
107
+ ),
108
+ );
109
+ return rows.map(rowToMailbox);
110
+ }
111
+ const [row] = await this.db
112
+ .select()
113
+ .from(mailboxTable)
114
+ .where(
115
+ and(
116
+ eq(mailboxTable.accountId, accountId),
117
+ eq(mailboxTable.mailboxId, mailboxId),
118
+ ),
119
+ );
120
+ if (!row) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
121
+ return rowToMailbox(row);
122
+ }
123
+
124
+ async update(
125
+ accountId: string,
126
+ mailboxId: string,
127
+ input: UpdateMailboxInput,
128
+ remove?: (keyof UpdateMailboxInput)[],
129
+ ): Promise<MailboxItem> {
130
+ const now = Date.now();
131
+ const updates: Partial<typeof mailboxTable.$inferInsert> = {
132
+ updatedAt: now,
133
+ };
134
+
135
+ if (input.namespaceType !== undefined)
136
+ updates.namespaceType = input.namespaceType;
137
+ if (input.namespacePrefix !== undefined)
138
+ updates.namespacePrefix = input.namespacePrefix;
139
+ if (input.hierarchyDelimiter !== undefined)
140
+ updates.hierarchyDelimiter = input.hierarchyDelimiter;
141
+ if (input.fullPath !== undefined) updates.fullPath = input.fullPath;
142
+ if (input.uidValidity !== undefined)
143
+ updates.uidValidity = input.uidValidity;
144
+ if (input.uidNext !== undefined) updates.uidNext = input.uidNext;
145
+ if (input.highestModseq !== undefined)
146
+ updates.highestModseq = input.highestModseq;
147
+ if (input.messageCount !== undefined)
148
+ updates.messageCount = input.messageCount;
149
+ if (input.unseenCount !== undefined)
150
+ updates.unseenCount = input.unseenCount;
151
+ if (input.deletedCount !== undefined)
152
+ updates.deletedCount = input.deletedCount;
153
+ if (input.totalSize !== undefined) updates.totalSize = input.totalSize;
154
+ if (input.lastSyncUid !== undefined)
155
+ updates.lastSyncUid = input.lastSyncUid;
156
+ if (input.highWaterMarkUid !== undefined)
157
+ updates.highWaterMarkUid = input.highWaterMarkUid;
158
+ if (input.lastMessageSyncAt !== undefined)
159
+ updates.lastMessageSyncAt = input.lastMessageSyncAt;
160
+ if (input.initialSyncCompletedAt !== undefined)
161
+ updates.initialSyncCompletedAt = input.initialSyncCompletedAt;
162
+ if (input.parentMailboxId !== undefined)
163
+ updates.parentMailboxId = input.parentMailboxId;
164
+ if (input.syncStatus !== undefined) updates.syncStatus = input.syncStatus;
165
+ if (input.cursorState !== undefined)
166
+ updates.cursorState = input.cursorState;
167
+ if (input.oldPath !== undefined) updates.oldPath = input.oldPath;
168
+ if (input.specialUse !== undefined) updates.specialUse = input.specialUse;
169
+
170
+ if (remove) {
171
+ for (const field of remove) {
172
+ if (field === "syncStatus") updates.syncStatus = null;
173
+ if (field === "oldPath") updates.oldPath = null;
174
+ if (field === "specialUse") updates.specialUse = null;
175
+ }
176
+ }
177
+
178
+ const [row] = await this.db
179
+ .update(mailboxTable)
180
+ .set(updates)
181
+ .where(
182
+ and(
183
+ eq(mailboxTable.accountId, accountId),
184
+ eq(mailboxTable.mailboxId, mailboxId),
185
+ ),
186
+ )
187
+ .returning();
188
+ if (!row) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
189
+ return rowToMailbox(row);
190
+ }
191
+
192
+ async resolveAccountId(mailboxId: string): Promise<string | null> {
193
+ const [row] = await this.db
194
+ .select({ accountId: mailboxTable.accountId })
195
+ .from(mailboxTable)
196
+ .where(eq(mailboxTable.mailboxId, mailboxId));
197
+ return row?.accountId ?? null;
198
+ }
199
+
200
+ async delete(accountId: string, mailboxId: string): Promise<void> {
201
+ await this.db
202
+ .delete(mailboxTable)
203
+ .where(
204
+ and(
205
+ eq(mailboxTable.accountId, accountId),
206
+ eq(mailboxTable.mailboxId, mailboxId),
207
+ ),
208
+ );
209
+ }
210
+
211
+ async deleteMany(accountId: string, mailboxIds: string[]): Promise<void> {
212
+ if (mailboxIds.length === 0) return;
213
+ await this.db
214
+ .delete(mailboxTable)
215
+ .where(
216
+ and(
217
+ eq(mailboxTable.accountId, accountId),
218
+ inArray(mailboxTable.mailboxId, mailboxIds),
219
+ ),
220
+ );
221
+ }
222
+
223
+ async listByAccount(
224
+ accountId: string,
225
+ options?: { limit?: number; continuationToken?: string },
226
+ ): Promise<ResultList<MailboxItem>> {
227
+ const limit = options?.limit ?? 100;
228
+ const cursor = options?.continuationToken
229
+ ? decodeToken(options.continuationToken)
230
+ : undefined;
231
+ const after = cursor
232
+ ? {
233
+ createdAt: cursor.createdAt as number,
234
+ mailboxId: cursor.mailboxId as string,
235
+ }
236
+ : undefined;
237
+
238
+ const rows = await this.db
239
+ .select()
240
+ .from(mailboxTable)
241
+ .where(
242
+ and(
243
+ eq(mailboxTable.accountId, accountId),
244
+ after
245
+ ? or(
246
+ gt(mailboxTable.createdAt, after.createdAt),
247
+ and(
248
+ eq(mailboxTable.createdAt, after.createdAt),
249
+ gt(mailboxTable.mailboxId, after.mailboxId),
250
+ ),
251
+ )
252
+ : undefined,
253
+ ),
254
+ )
255
+ .orderBy(asc(mailboxTable.createdAt), asc(mailboxTable.mailboxId))
256
+ .limit(limit + 1);
257
+
258
+ const hasMore = rows.length > limit;
259
+ const items = rows.slice(0, limit).map(rowToMailbox);
260
+ const lastItem = items[items.length - 1];
261
+ return resultList(
262
+ items,
263
+ limit,
264
+ hasMore && lastItem
265
+ ? { createdAt: lastItem.createdAt, mailboxId: lastItem.mailboxId }
266
+ : undefined,
267
+ );
268
+ }
269
+
270
+ async listAllByAccount(accountId: string): Promise<MailboxItem[]> {
271
+ const rows = await this.db
272
+ .select()
273
+ .from(mailboxTable)
274
+ .where(eq(mailboxTable.accountId, accountId));
275
+ return rows.map(rowToMailbox);
276
+ }
277
+
278
+ async findByPath(
279
+ accountId: string,
280
+ fullPath: string,
281
+ ): Promise<MailboxItem | null> {
282
+ const [row] = await this.db
283
+ .select()
284
+ .from(mailboxTable)
285
+ .where(
286
+ and(
287
+ eq(mailboxTable.accountId, accountId),
288
+ eq(mailboxTable.fullPath, fullPath),
289
+ ),
290
+ );
291
+ return row ? rowToMailbox(row) : null;
292
+ }
293
+
294
+ async getOrCreateByPath(
295
+ accountId: string,
296
+ fullPath: string,
297
+ defaults: Omit<CreateMailboxInput, "accountId" | "fullPath">,
298
+ ): Promise<MailboxItem> {
299
+ const existing = await this.findByPath(accountId, fullPath);
300
+ if (existing) return existing;
301
+ return this.create({ accountId, fullPath, ...defaults });
302
+ }
303
+
304
+ async findByPathPrefix(
305
+ accountId: string,
306
+ pathPrefix: string,
307
+ delimiter = "/",
308
+ ): Promise<MailboxItem[]> {
309
+ const rows = await this.db
310
+ .select()
311
+ .from(mailboxTable)
312
+ .where(eq(mailboxTable.accountId, accountId));
313
+ const fullPrefix = `${pathPrefix}${delimiter}`;
314
+ return rows
315
+ .filter((r) => r.fullPath.startsWith(fullPrefix))
316
+ .map(rowToMailbox);
317
+ }
318
+
319
+ async findBySyncStatus(
320
+ accountId: string,
321
+ syncStatus: NonNullable<MailboxItem["syncStatus"]>,
322
+ ): Promise<MailboxItem[]> {
323
+ const rows = await this.db
324
+ .select()
325
+ .from(mailboxTable)
326
+ .where(
327
+ and(
328
+ eq(mailboxTable.accountId, accountId),
329
+ eq(mailboxTable.syncStatus, syncStatus),
330
+ ),
331
+ );
332
+ return rows.map(rowToMailbox);
333
+ }
334
+
335
+ async renameChildPaths(
336
+ accountId: string,
337
+ oldPath: string,
338
+ newPath: string,
339
+ delimiter = "/",
340
+ ): Promise<void> {
341
+ const children = await this.findByPathPrefix(accountId, oldPath, delimiter);
342
+ for (const child of children) {
343
+ const newChildPath = child.fullPath.replace(oldPath, newPath);
344
+ await this.update(accountId, child.mailboxId, { fullPath: newChildPath });
345
+ }
346
+ }
347
+ }