@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,368 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
4
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
+ import { MailboxLockRepo } from "./i4-mailbox-lock.js";
6
+
7
+ describe("MailboxLockRepo", () => {
8
+ let db: TestDb;
9
+ let close: () => Promise<void>;
10
+ let repo: MailboxLockRepo;
11
+
12
+ async function seedExpiredLock(input: {
13
+ mailboxId: string;
14
+ eventName: string;
15
+ accountId: string;
16
+ lockId: string;
17
+ }): Promise<void> {
18
+ await db.insert(mailboxLockTable).values({
19
+ ...input,
20
+ lockedBy: input.eventName,
21
+ acquiredAt: Date.now() - 3_600_000,
22
+ ttl: Math.floor(Date.now() / 1000) - 60,
23
+ });
24
+ }
25
+
26
+ before(async () => {
27
+ ({ db, close } = await createTestDb());
28
+ repo = new MailboxLockRepo(db as never);
29
+ });
30
+
31
+ after(async () => {
32
+ await close();
33
+ });
34
+
35
+ test("tryAcquireLock acquires lock when none exists", async () => {
36
+ const mailboxId = randomId();
37
+ const accountId = randomId();
38
+ const lockId = randomId();
39
+ const eventName = "SYNC_MESSAGES";
40
+
41
+ const acquired = await repo.tryAcquireLock(
42
+ mailboxId,
43
+ eventName,
44
+ accountId,
45
+ lockId,
46
+ );
47
+ assert.equal(acquired, true, "should acquire lock");
48
+
49
+ const lock = await repo.get(accountId, mailboxId, eventName);
50
+ assert.ok(lock, "lock should exist");
51
+ assert.equal(lock.mailboxId, mailboxId);
52
+ assert.equal(lock.eventName, eventName);
53
+ assert.equal(lock.accountId, accountId);
54
+ assert.equal(lock.lockId, lockId);
55
+ assert.ok(lock.acquiredAt > 0);
56
+ assert.ok(lock.ttl > 0);
57
+
58
+ // cleanup
59
+ await repo.releaseLock(accountId, mailboxId, eventName, lockId);
60
+ });
61
+
62
+ test("tryAcquireLock fails (returns false) when lock already held", async () => {
63
+ const mailboxId = randomId();
64
+ const accountId = randomId();
65
+ const lockId1 = randomId();
66
+ const lockId2 = randomId();
67
+ const eventName = "SYNC_MESSAGES";
68
+
69
+ const first = await repo.tryAcquireLock(
70
+ mailboxId,
71
+ eventName,
72
+ accountId,
73
+ lockId1,
74
+ );
75
+ assert.equal(first, true, "first acquire should succeed");
76
+
77
+ const second = await repo.tryAcquireLock(
78
+ mailboxId,
79
+ eventName,
80
+ accountId,
81
+ lockId2,
82
+ );
83
+ assert.equal(second, false, "second acquire should fail");
84
+
85
+ await repo.releaseLock(accountId, mailboxId, eventName, lockId1);
86
+ });
87
+
88
+ test("tryAcquireLock steals an expired lock", async () => {
89
+ const mailboxId = randomId();
90
+ const accountId = randomId();
91
+ const staleLockId = randomId();
92
+ const freshLockId = randomId();
93
+ const eventName = "SYNC_MESSAGES";
94
+
95
+ await seedExpiredLock({
96
+ mailboxId,
97
+ eventName,
98
+ accountId,
99
+ lockId: staleLockId,
100
+ });
101
+
102
+ const acquired = await repo.tryAcquireLock(
103
+ mailboxId,
104
+ eventName,
105
+ accountId,
106
+ freshLockId,
107
+ );
108
+ assert.equal(acquired, true, "should steal the expired lock");
109
+
110
+ const lock = await repo.get(accountId, mailboxId, eventName);
111
+ assert.ok(lock, "lock should exist");
112
+ assert.equal(
113
+ lock.lockId,
114
+ freshLockId,
115
+ "lock should be owned by new holder",
116
+ );
117
+ assert.ok(
118
+ lock.ttl > Math.floor(Date.now() / 1000),
119
+ "ttl should be renewed",
120
+ );
121
+
122
+ await repo.releaseLock(accountId, mailboxId, eventName, freshLockId);
123
+ });
124
+
125
+ test("tryAcquireLock refuses to steal a live lock", async () => {
126
+ const mailboxId = randomId();
127
+ const accountId = randomId();
128
+ const liveLockId = randomId();
129
+ const contenderLockId = randomId();
130
+ const eventName = "SYNC_MESSAGES";
131
+
132
+ await repo.tryAcquireLock(mailboxId, eventName, accountId, liveLockId);
133
+
134
+ const acquired = await repo.tryAcquireLock(
135
+ mailboxId,
136
+ eventName,
137
+ accountId,
138
+ contenderLockId,
139
+ );
140
+ assert.equal(acquired, false, "should not steal a live lock");
141
+
142
+ const lock = await repo.get(accountId, mailboxId, eventName);
143
+ assert.ok(lock, "lock should still exist");
144
+ assert.equal(
145
+ lock.lockId,
146
+ liveLockId,
147
+ "live lock owner should be unchanged",
148
+ );
149
+
150
+ await repo.releaseLock(accountId, mailboxId, eventName, liveLockId);
151
+ });
152
+
153
+ test("only one contender wins when stealing the same expired lock", async () => {
154
+ const mailboxId = randomId();
155
+ const accountId = randomId();
156
+ const staleLockId = randomId();
157
+ const contenderA = randomId();
158
+ const contenderB = randomId();
159
+ const eventName = "SYNC_MESSAGES";
160
+
161
+ await seedExpiredLock({
162
+ mailboxId,
163
+ eventName,
164
+ accountId,
165
+ lockId: staleLockId,
166
+ });
167
+
168
+ const results = await Promise.all([
169
+ repo.tryAcquireLock(mailboxId, eventName, accountId, contenderA),
170
+ repo.tryAcquireLock(mailboxId, eventName, accountId, contenderB),
171
+ ]);
172
+
173
+ assert.equal(
174
+ results.filter(Boolean).length,
175
+ 1,
176
+ "exactly one contender should win",
177
+ );
178
+
179
+ const lock = await repo.get(accountId, mailboxId, eventName);
180
+ assert.ok(lock);
181
+ await repo.releaseLock(accountId, mailboxId, eventName, lock.lockId);
182
+ });
183
+
184
+ test("releaseLock removes the lock when lockId matches", async () => {
185
+ const mailboxId = randomId();
186
+ const accountId = randomId();
187
+ const lockId = randomId();
188
+ const eventName = "SYNC_MESSAGES";
189
+
190
+ await repo.tryAcquireLock(mailboxId, eventName, accountId, lockId);
191
+ await repo.releaseLock(accountId, mailboxId, eventName, lockId);
192
+
193
+ const lock = await repo.get(accountId, mailboxId, eventName);
194
+ assert.equal(lock, null, "lock should be released");
195
+ });
196
+
197
+ test("releaseLock is a no-op when lockId does not match", async () => {
198
+ const mailboxId = randomId();
199
+ const accountId = randomId();
200
+ const lockId1 = randomId();
201
+ const lockId2 = randomId();
202
+ const eventName = "SYNC_MESSAGES";
203
+
204
+ await repo.tryAcquireLock(mailboxId, eventName, accountId, lockId1);
205
+ await repo.releaseLock(accountId, mailboxId, eventName, lockId2); // wrong lockId
206
+
207
+ const lock = await repo.get(accountId, mailboxId, eventName);
208
+ assert.ok(lock, "lock should still exist");
209
+ assert.equal(lock.lockId, lockId1);
210
+
211
+ await repo.releaseLock(accountId, mailboxId, eventName, lockId1);
212
+ });
213
+
214
+ test("withMailboxLock executes and releases", async () => {
215
+ const mailboxId = randomId();
216
+ const accountId = randomId();
217
+ const eventName = "SYNC_MESSAGES";
218
+
219
+ let ran = false;
220
+ const result = await repo.withMailboxLock(
221
+ mailboxId,
222
+ eventName,
223
+ accountId,
224
+ async () => {
225
+ ran = true;
226
+ return "done";
227
+ },
228
+ );
229
+
230
+ assert.equal(result.executed, true);
231
+ assert.equal(result.result, "done");
232
+ assert.equal(ran, true);
233
+
234
+ const lock = await repo.get(accountId, mailboxId, eventName);
235
+ assert.equal(lock, null, "lock released after operation");
236
+ });
237
+
238
+ test("withMailboxLock skips when lock already held", async () => {
239
+ const mailboxId = randomId();
240
+ const accountId = randomId();
241
+ const lockId = randomId();
242
+ const eventName = "SYNC_MESSAGES";
243
+
244
+ await repo.tryAcquireLock(mailboxId, eventName, accountId, lockId);
245
+
246
+ const result = await repo.withMailboxLock(
247
+ mailboxId,
248
+ eventName,
249
+ accountId,
250
+ async () => "should not run",
251
+ );
252
+
253
+ assert.equal(result.executed, false);
254
+
255
+ await repo.releaseLock(accountId, mailboxId, eventName, lockId);
256
+ });
257
+
258
+ test("withMailboxLock releases even if operation throws", async () => {
259
+ const mailboxId = randomId();
260
+ const accountId = randomId();
261
+ const eventName = "SYNC_MESSAGES";
262
+
263
+ await assert.rejects(
264
+ repo.withMailboxLock(mailboxId, eventName, accountId, async () => {
265
+ throw new Error("boom");
266
+ }),
267
+ /boom/,
268
+ );
269
+
270
+ const lock = await repo.get(accountId, mailboxId, eventName);
271
+ assert.equal(lock, null, "lock released after throw");
272
+ });
273
+
274
+ test("get returns null when no lock exists", async () => {
275
+ const lock = await repo.get(randomId(), randomId(), "SYNC_MESSAGES");
276
+ assert.equal(lock, null);
277
+ });
278
+
279
+ test("different eventNames create separate locks", async () => {
280
+ const mailboxId = randomId();
281
+ const accountId = randomId();
282
+ const lockId1 = randomId();
283
+ const lockId2 = randomId();
284
+
285
+ const a1 = await repo.tryAcquireLock(
286
+ mailboxId,
287
+ "SYNC_MESSAGES",
288
+ accountId,
289
+ lockId1,
290
+ );
291
+ const a2 = await repo.tryAcquireLock(
292
+ mailboxId,
293
+ "SYNC_FLAGS",
294
+ accountId,
295
+ lockId2,
296
+ );
297
+
298
+ assert.equal(a1, true);
299
+ assert.equal(a2, true);
300
+
301
+ await repo.releaseLock(accountId, mailboxId, "SYNC_MESSAGES", lockId1);
302
+ await repo.releaseLock(accountId, mailboxId, "SYNC_FLAGS", lockId2);
303
+ });
304
+
305
+ test("listByAccount returns locks for that account only", async () => {
306
+ const mailboxId1 = randomId();
307
+ const mailboxId2 = randomId();
308
+ const accountId1 = randomId();
309
+ const accountId2 = randomId();
310
+ const lockId1 = randomId();
311
+ const lockId2 = randomId();
312
+ const eventName = "SYNC_MESSAGES";
313
+
314
+ await repo.tryAcquireLock(mailboxId1, eventName, accountId1, lockId1);
315
+ await repo.tryAcquireLock(mailboxId2, eventName, accountId2, lockId2);
316
+
317
+ const locks = await repo.listByAccount(accountId1);
318
+ assert.equal(locks.filter((l) => l.lockId === lockId1).length, 1);
319
+ assert.equal(locks.filter((l) => l.lockId === lockId2).length, 0);
320
+
321
+ await repo.releaseLock(accountId1, mailboxId1, eventName, lockId1);
322
+ await repo.releaseLock(accountId2, mailboxId2, eventName, lockId2);
323
+ });
324
+
325
+ test("deleteByAccount removes all locks for that account", async () => {
326
+ const accountId = randomId();
327
+ const lockId1 = randomId();
328
+ const lockId2 = randomId();
329
+ const mb1 = randomId();
330
+ const mb2 = randomId();
331
+
332
+ await repo.tryAcquireLock(mb1, "SYNC_MESSAGES", accountId, lockId1);
333
+ await repo.tryAcquireLock(mb2, "SYNC_FLAGS", accountId, lockId2);
334
+
335
+ await repo.deleteByAccount(accountId);
336
+
337
+ const locks = await repo.listByAccount(accountId);
338
+ assert.equal(locks.length, 0);
339
+ });
340
+
341
+ test("get is scoped to accountId (foreign account sees nothing)", async () => {
342
+ const accountId = randomId();
343
+ const other = randomId();
344
+ const mailboxId = randomId();
345
+ const lockId = randomId();
346
+ await repo.tryAcquireLock(mailboxId, "SYNC_MESSAGES", accountId, lockId);
347
+
348
+ assert.ok(await repo.get(accountId, mailboxId, "SYNC_MESSAGES"));
349
+ assert.equal(await repo.get(other, mailboxId, "SYNC_MESSAGES"), null);
350
+
351
+ await repo.releaseLock(accountId, mailboxId, "SYNC_MESSAGES", lockId);
352
+ });
353
+
354
+ test("releaseLock does not release another account's lock", async () => {
355
+ const accountId = randomId();
356
+ const other = randomId();
357
+ const mailboxId = randomId();
358
+ const lockId = randomId();
359
+ await repo.tryAcquireLock(mailboxId, "SYNC_MESSAGES", accountId, lockId);
360
+
361
+ // Foreign account with the (leaked) lockId still cannot release it.
362
+ await repo.releaseLock(other, mailboxId, "SYNC_MESSAGES", lockId);
363
+ assert.ok(await repo.get(accountId, mailboxId, "SYNC_MESSAGES"));
364
+
365
+ await repo.releaseLock(accountId, mailboxId, "SYNC_MESSAGES", lockId);
366
+ assert.equal(await repo.get(accountId, mailboxId, "SYNC_MESSAGES"), null);
367
+ });
368
+ });
@@ -0,0 +1,140 @@
1
+ import type {
2
+ IMailboxLockRepository,
3
+ MailboxLockItem,
4
+ WithMailboxLockResult,
5
+ } from "@remit/data-ports";
6
+ import { and, eq, lt } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import { randomId } from "../id.js";
9
+ import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
10
+
11
+ type DB = NodePgDatabase<Record<string, unknown>>;
12
+
13
+ const TTL_SECONDS = 3600;
14
+
15
+ function rowToItem(row: typeof mailboxLockTable.$inferSelect): MailboxLockItem {
16
+ return {
17
+ mailboxId: row.mailboxId,
18
+ eventName: row.eventName,
19
+ accountId: row.accountId,
20
+ lockId: row.lockId,
21
+ acquiredAt: row.acquiredAt,
22
+ lockedBy: row.lockedBy,
23
+ ttl: row.ttl,
24
+ };
25
+ }
26
+
27
+ export class MailboxLockRepo implements IMailboxLockRepository {
28
+ constructor(private db: DB) {}
29
+
30
+ async tryAcquireLock(
31
+ mailboxId: string,
32
+ eventName: string,
33
+ accountId: string,
34
+ lockId: string,
35
+ ): Promise<boolean> {
36
+ const now = Date.now();
37
+ const nowSeconds = Math.floor(now / 1000);
38
+ const ttl = nowSeconds + TTL_SECONDS;
39
+
40
+ const rows = await this.db
41
+ .insert(mailboxLockTable)
42
+ .values({
43
+ mailboxId,
44
+ eventName,
45
+ accountId,
46
+ lockId,
47
+ acquiredAt: now,
48
+ lockedBy: eventName,
49
+ ttl,
50
+ })
51
+ .onConflictDoUpdate({
52
+ target: [mailboxLockTable.mailboxId, mailboxLockTable.eventName],
53
+ set: { accountId, lockId, acquiredAt: now, lockedBy: eventName, ttl },
54
+ setWhere: lt(mailboxLockTable.ttl, nowSeconds),
55
+ })
56
+ .returning();
57
+
58
+ return rows.length > 0;
59
+ }
60
+
61
+ async releaseLock(
62
+ accountId: string,
63
+ mailboxId: string,
64
+ eventName: string,
65
+ lockId: string,
66
+ ): Promise<void> {
67
+ await this.db
68
+ .delete(mailboxLockTable)
69
+ .where(
70
+ and(
71
+ eq(mailboxLockTable.accountId, accountId),
72
+ eq(mailboxLockTable.mailboxId, mailboxId),
73
+ eq(mailboxLockTable.eventName, eventName),
74
+ eq(mailboxLockTable.lockId, lockId),
75
+ ),
76
+ );
77
+ }
78
+
79
+ async withMailboxLock<T>(
80
+ mailboxId: string,
81
+ eventName: string,
82
+ accountId: string,
83
+ operation: () => Promise<T>,
84
+ options?: { lockId?: string },
85
+ ): Promise<WithMailboxLockResult<T>> {
86
+ const lockId = options?.lockId ?? randomId();
87
+
88
+ const acquired = await this.tryAcquireLock(
89
+ mailboxId,
90
+ eventName,
91
+ accountId,
92
+ lockId,
93
+ );
94
+ if (!acquired) return { executed: false };
95
+
96
+ return operation()
97
+ .then((result) => ({ executed: true as const, result }))
98
+ .finally(() => this.releaseLock(accountId, mailboxId, eventName, lockId));
99
+ }
100
+
101
+ async get(
102
+ accountId: string,
103
+ mailboxId: string,
104
+ eventName: string,
105
+ ): Promise<MailboxLockItem | null> {
106
+ const [row] = await this.db
107
+ .select()
108
+ .from(mailboxLockTable)
109
+ .where(
110
+ and(
111
+ eq(mailboxLockTable.accountId, accountId),
112
+ eq(mailboxLockTable.mailboxId, mailboxId),
113
+ eq(mailboxLockTable.eventName, eventName),
114
+ ),
115
+ );
116
+ return row ? rowToItem(row) : null;
117
+ }
118
+
119
+ async listAllLocks(): Promise<MailboxLockItem[]> {
120
+ const rows = await this.db
121
+ .select()
122
+ .from(mailboxLockTable)
123
+ .orderBy(mailboxLockTable.acquiredAt);
124
+ return rows.map(rowToItem);
125
+ }
126
+
127
+ async listByAccount(accountId: string): Promise<MailboxLockItem[]> {
128
+ const rows = await this.db
129
+ .select()
130
+ .from(mailboxLockTable)
131
+ .where(eq(mailboxLockTable.accountId, accountId));
132
+ return rows.map(rowToItem);
133
+ }
134
+
135
+ async deleteByAccount(accountId: string): Promise<void> {
136
+ await this.db
137
+ .delete(mailboxLockTable)
138
+ .where(eq(mailboxLockTable.accountId, accountId));
139
+ }
140
+ }
@@ -0,0 +1,165 @@
1
+ import type {
2
+ IMailboxSpecialUseRepository,
3
+ MailboxSpecialUseItem,
4
+ MailboxSpecialUseValue,
5
+ } from "@remit/data-ports";
6
+ import { eq } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import { randomId } from "../id.js";
9
+ import { mailboxSpecialUseTable, mailboxTable } from "../schema/i4-mailbox.js";
10
+
11
+ type DB = NodePgDatabase<Record<string, unknown>>;
12
+
13
+ function rowToSpecialUse(
14
+ row: typeof mailboxSpecialUseTable.$inferSelect,
15
+ ): MailboxSpecialUseItem {
16
+ return {
17
+ mailboxSpecialUseId: row.mailboxSpecialUseId,
18
+ mailboxId: row.mailboxId,
19
+ specialUse: row.specialUse as MailboxSpecialUseValue,
20
+ };
21
+ }
22
+
23
+ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
24
+ constructor(private db: DB) {}
25
+
26
+ async create(
27
+ mailboxId: string,
28
+ specialUse: MailboxSpecialUseValue,
29
+ ): Promise<MailboxSpecialUseItem> {
30
+ const [row] = await this.db
31
+ .insert(mailboxSpecialUseTable)
32
+ .values({
33
+ mailboxSpecialUseId: randomId(),
34
+ mailboxId,
35
+ specialUse,
36
+ })
37
+ .returning();
38
+ return rowToSpecialUse(row);
39
+ }
40
+
41
+ async createMany(
42
+ mailboxId: string,
43
+ specialUses: MailboxSpecialUseValue[],
44
+ ): Promise<MailboxSpecialUseItem[]> {
45
+ if (specialUses.length === 0) return [];
46
+ const values = specialUses.map((specialUse) => ({
47
+ mailboxSpecialUseId: randomId(),
48
+ mailboxId,
49
+ specialUse,
50
+ }));
51
+ const rows = await this.db
52
+ .insert(mailboxSpecialUseTable)
53
+ .values(values)
54
+ .returning();
55
+ return rows.map(rowToSpecialUse);
56
+ }
57
+
58
+ async listByMailboxId(mailboxId: string): Promise<MailboxSpecialUseItem[]> {
59
+ const rows = await this.db
60
+ .select()
61
+ .from(mailboxSpecialUseTable)
62
+ .where(eq(mailboxSpecialUseTable.mailboxId, mailboxId));
63
+ return rows.map(rowToSpecialUse);
64
+ }
65
+
66
+ async deleteByMailboxId(mailboxId: string): Promise<number> {
67
+ const existing = await this.listByMailboxId(mailboxId);
68
+ if (existing.length === 0) return 0;
69
+ await this.db
70
+ .delete(mailboxSpecialUseTable)
71
+ .where(eq(mailboxSpecialUseTable.mailboxId, mailboxId));
72
+ return existing.length;
73
+ }
74
+
75
+ async findBySpecialUse(
76
+ accountId: string,
77
+ specialUse: MailboxSpecialUseValue,
78
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
79
+ const mailboxes = await this.db
80
+ .select()
81
+ .from(mailboxTable)
82
+ .where(eq(mailboxTable.accountId, accountId));
83
+
84
+ for (const mailbox of mailboxes) {
85
+ const entries = await this.listByMailboxId(mailbox.mailboxId);
86
+ if (entries.some((e) => e.specialUse === specialUse)) {
87
+ return { mailboxId: mailbox.mailboxId, fullPath: mailbox.fullPath };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ async findInboxMailbox(
94
+ accountId: string,
95
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
96
+ const rows = await this.db
97
+ .select()
98
+ .from(mailboxTable)
99
+ .where(eq(mailboxTable.accountId, accountId));
100
+ const found = rows.find((r) => r.fullPath.toUpperCase() === "INBOX");
101
+ return found
102
+ ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
103
+ : null;
104
+ }
105
+
106
+ async findTrashMailbox(
107
+ accountId: string,
108
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
109
+ const bySpecialUse = await this.findBySpecialUse(accountId, "Trash");
110
+ if (bySpecialUse) return bySpecialUse;
111
+
112
+ const rows = await this.db
113
+ .select()
114
+ .from(mailboxTable)
115
+ .where(eq(mailboxTable.accountId, accountId));
116
+
117
+ const names = [
118
+ "trash",
119
+ "deleted items",
120
+ "deleted",
121
+ "[gmail]/trash",
122
+ "[gmail]/bin",
123
+ ];
124
+ const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
125
+ return found
126
+ ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
127
+ : null;
128
+ }
129
+
130
+ async findArchiveMailbox(
131
+ accountId: string,
132
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
133
+ const bySpecialUse = await this.findBySpecialUse(accountId, "Archive");
134
+ if (bySpecialUse) return bySpecialUse;
135
+
136
+ const rows = await this.db
137
+ .select()
138
+ .from(mailboxTable)
139
+ .where(eq(mailboxTable.accountId, accountId));
140
+
141
+ const names = ["archive", "archives", "[gmail]/all mail"];
142
+ const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
143
+ return found
144
+ ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
145
+ : null;
146
+ }
147
+
148
+ async findJunkMailbox(
149
+ accountId: string,
150
+ ): Promise<{ mailboxId: string; fullPath: string } | null> {
151
+ const bySpecialUse = await this.findBySpecialUse(accountId, "Junk");
152
+ if (bySpecialUse) return bySpecialUse;
153
+
154
+ const rows = await this.db
155
+ .select()
156
+ .from(mailboxTable)
157
+ .where(eq(mailboxTable.accountId, accountId));
158
+
159
+ const names = ["junk", "spam", "bulk mail", "junk e-mail", "[gmail]/spam"];
160
+ const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
161
+ return found
162
+ ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
163
+ : null;
164
+ }
165
+ }