@remit/drizzle-service 0.0.13 → 0.0.15
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 +1 -1
- package/src/index.ts +2 -0
- package/src/repos/cascade-delete.sqlite.test.ts +4 -1
- package/src/repos/cascade-delete.test.ts +31 -0
- package/src/repos/cascade-delete.ts +8 -0
- package/src/repos/quarantine.test.ts +112 -0
- package/src/repos/quarantine.ts +57 -0
- package/src/repos/thread-message.test.ts +275 -0
- package/src/repos/thread-message.ts +59 -0
- package/src/schema/quarantine.ts +3 -0
- package/src/schema.ts +1 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
} from "./repos/message.js";
|
|
41
41
|
export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
|
|
42
42
|
export { MessageLabelRepo } from "./repos/message-label.js";
|
|
43
|
+
export { QuarantineRepo } from "./repos/quarantine.js";
|
|
43
44
|
export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
|
|
44
45
|
export { DrizzleUnitOfWork } from "./repos/unit-of-work.js";
|
|
45
46
|
export * from "./schema/i4-account-config.js";
|
|
@@ -54,6 +55,7 @@ export * from "./schema/i4-organize-job-request.js";
|
|
|
54
55
|
export * from "./schema/i4-outbox-message.js";
|
|
55
56
|
export * from "./schema/message-data.js";
|
|
56
57
|
export { messageDataSchema } from "./schema/message-data.js";
|
|
58
|
+
export * from "./schema/quarantine.js";
|
|
57
59
|
export {
|
|
58
60
|
createSqliteDatabase,
|
|
59
61
|
type SqliteClient,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
envelopeTable,
|
|
12
12
|
mailboxTable,
|
|
13
13
|
messageTable,
|
|
14
|
+
quarantineTable,
|
|
14
15
|
} from "../schema.js";
|
|
15
16
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
16
17
|
import {
|
|
@@ -22,11 +23,13 @@ import {
|
|
|
22
23
|
// this harness pushes must live on a real file (not the in-memory default) for
|
|
23
24
|
// the deleter's own connection to see it. `deleteMessageSubtree` touches every
|
|
24
25
|
// message-data child table, so push the full message-data schema plus the
|
|
25
|
-
// account and mailbox containers
|
|
26
|
+
// account and mailbox containers, plus quarantine — the cascade deletes those
|
|
27
|
+
// rows set-wise by account, so the table has to exist here too.
|
|
26
28
|
const CASCADE_SCHEMA = {
|
|
27
29
|
...messageDataSchema,
|
|
28
30
|
account: accountTable,
|
|
29
31
|
mailbox: mailboxTable,
|
|
32
|
+
quarantine: quarantineTable,
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
const log = { info: () => {} };
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
messageTable,
|
|
17
17
|
outboxMessageTable,
|
|
18
18
|
outboxTable,
|
|
19
|
+
quarantineTable,
|
|
19
20
|
threadMessageTable,
|
|
20
21
|
} from "../schema.js";
|
|
21
22
|
import { createTestDb, type TestDb } from "../test-db.js";
|
|
@@ -40,6 +41,7 @@ const ROOT_BODY_PART_ID = "00000000-0000-4000-8000-000000000002";
|
|
|
40
41
|
const MESSAGE_FLAG_ID = "00000000-0000-4000-8000-000000000003";
|
|
41
42
|
const BODY_PART_ID = "00000000-0000-4000-8000-000000000004";
|
|
42
43
|
const OTHER_MAILBOX_ID = "mbx-cascade-2";
|
|
44
|
+
const QUARANTINE_ID = "quarantine-cascade-1";
|
|
43
45
|
|
|
44
46
|
describe("runDrizzleCascadeDelete", () => {
|
|
45
47
|
let db: TestDb;
|
|
@@ -228,6 +230,25 @@ describe("runDrizzleCascadeDelete", () => {
|
|
|
228
230
|
updatedAt: NOW,
|
|
229
231
|
},
|
|
230
232
|
]);
|
|
233
|
+
|
|
234
|
+
await db.insert(quarantineTable).values({
|
|
235
|
+
quarantineId: QUARANTINE_ID,
|
|
236
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
237
|
+
accountId: ACCOUNT_ID,
|
|
238
|
+
mailboxId: MAILBOX_ID,
|
|
239
|
+
uidValidity: 1_712_000_000,
|
|
240
|
+
uid: 40217,
|
|
241
|
+
mailboxPath: "Clients/Acme Holdings",
|
|
242
|
+
quarantinedAt: NOW,
|
|
243
|
+
attempts: 3,
|
|
244
|
+
failureStage: "BodyParse",
|
|
245
|
+
failureCode: "UnterminatedMultipartBoundary",
|
|
246
|
+
failureMessage: "multipart boundary was never closed",
|
|
247
|
+
workerVersion: "worker 1.0.0",
|
|
248
|
+
structure: [{ depth: 0, contentType: "multipart/mixed" }],
|
|
249
|
+
createdAt: NOW,
|
|
250
|
+
updatedAt: NOW,
|
|
251
|
+
});
|
|
231
252
|
});
|
|
232
253
|
|
|
233
254
|
after(async () => {
|
|
@@ -397,6 +418,16 @@ describe("runDrizzleCascadeDelete", () => {
|
|
|
397
418
|
0,
|
|
398
419
|
"both the Seen and Flagged markers (composite key) must be deleted",
|
|
399
420
|
);
|
|
421
|
+
assert.equal(
|
|
422
|
+
(
|
|
423
|
+
await db
|
|
424
|
+
.select()
|
|
425
|
+
.from(quarantineTable)
|
|
426
|
+
.where(eq(quarantineTable.quarantineId, QUARANTINE_ID))
|
|
427
|
+
).length,
|
|
428
|
+
0,
|
|
429
|
+
"quarantine rows carry the user's folder names and parser output, so account deletion must take them",
|
|
430
|
+
);
|
|
400
431
|
});
|
|
401
432
|
|
|
402
433
|
test("emits a message.removed outbox row for search-index cleanup", async () => {
|
|
@@ -9,6 +9,7 @@ import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
|
|
|
9
9
|
import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
|
|
10
10
|
import { outboxMessageTable } from "../schema/i4-outbox-message.js";
|
|
11
11
|
import { messageDataSchema } from "../schema/message-data.js";
|
|
12
|
+
import { quarantineTable } from "../schema/quarantine.js";
|
|
12
13
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
13
14
|
import { createSqliteDatabase } from "../sqlite-client.js";
|
|
14
15
|
import { runInTransaction } from "../tx.js";
|
|
@@ -151,6 +152,13 @@ export const runDrizzleCascadeDelete = async (
|
|
|
151
152
|
|
|
152
153
|
const accountIds = (grouped.get("Account") ?? []).map((k) => k.accountId);
|
|
153
154
|
if (accountIds.length > 0) {
|
|
155
|
+
// Quarantine rows are deleted set-wise by account rather than from an
|
|
156
|
+
// enumerated key, the way message subtrees are. They carry the user's
|
|
157
|
+
// own folder names and parser output quoting the message, so account
|
|
158
|
+
// deletion has to take them even though nothing enumerates them.
|
|
159
|
+
await tx
|
|
160
|
+
.delete(quarantineTable)
|
|
161
|
+
.where(inArray(quarantineTable.accountId, accountIds));
|
|
154
162
|
await tx
|
|
155
163
|
.delete(accountTable)
|
|
156
164
|
.where(inArray(accountTable.accountId, accountIds));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { quarantineTable } from "../schema/quarantine.js";
|
|
4
|
+
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
5
|
+
import { QuarantineRepo } from "./quarantine.js";
|
|
6
|
+
|
|
7
|
+
describe("QuarantineRepo", () => {
|
|
8
|
+
let db: TestDb;
|
|
9
|
+
let close: () => Promise<void>;
|
|
10
|
+
let repo: QuarantineRepo;
|
|
11
|
+
|
|
12
|
+
const seed = async (
|
|
13
|
+
overrides: Partial<typeof quarantineTable.$inferInsert> & {
|
|
14
|
+
accountConfigId: string;
|
|
15
|
+
},
|
|
16
|
+
): Promise<string> => {
|
|
17
|
+
const quarantineId = randomId();
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
await db.insert(quarantineTable).values({
|
|
20
|
+
quarantineId,
|
|
21
|
+
accountId: randomId(),
|
|
22
|
+
mailboxId: randomId(),
|
|
23
|
+
uidValidity: 1_712_000_000,
|
|
24
|
+
uid: 40217,
|
|
25
|
+
mailboxPath: "INBOX",
|
|
26
|
+
quarantinedAt: now,
|
|
27
|
+
attempts: 3,
|
|
28
|
+
failureStage: "BodyParse",
|
|
29
|
+
failureCode: "UnterminatedMultipartBoundary",
|
|
30
|
+
failureMessage: "multipart boundary was never closed",
|
|
31
|
+
workerVersion: "worker 1.0.0",
|
|
32
|
+
structure: [{ depth: 0, contentType: "multipart/mixed" }],
|
|
33
|
+
createdAt: now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
return quarantineId;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
before(async () => {
|
|
41
|
+
({ db, close } = await createTestDb());
|
|
42
|
+
repo = new QuarantineRepo(db as never);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
after(async () => {
|
|
46
|
+
await close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("lists one user's entries newest first", async () => {
|
|
50
|
+
const accountConfigId = randomId();
|
|
51
|
+
const older = await seed({ accountConfigId, quarantinedAt: 1_000 });
|
|
52
|
+
const newer = await seed({ accountConfigId, quarantinedAt: 2_000 });
|
|
53
|
+
|
|
54
|
+
const entries = await repo.listByAccountConfigId(accountConfigId);
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
entries.map((entry) => entry.quarantineId),
|
|
58
|
+
[newer, older],
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("never returns another user's entries", async () => {
|
|
63
|
+
const mine = randomId();
|
|
64
|
+
await seed({ accountConfigId: mine });
|
|
65
|
+
await seed({ accountConfigId: randomId() });
|
|
66
|
+
|
|
67
|
+
const entries = await repo.listByAccountConfigId(mine);
|
|
68
|
+
|
|
69
|
+
assert.equal(entries.length, 1);
|
|
70
|
+
assert.equal(entries[0].accountConfigId, mine);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("returns the MIME tree as the pre-order walk it was written as", async () => {
|
|
74
|
+
const accountConfigId = randomId();
|
|
75
|
+
await seed({
|
|
76
|
+
accountConfigId,
|
|
77
|
+
structure: [
|
|
78
|
+
{ depth: 0, contentType: "multipart/mixed" },
|
|
79
|
+
{ depth: 1, contentType: "text/plain" },
|
|
80
|
+
{ depth: 1, contentType: "application/pdf" },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const [entry] = await repo.listByAccountConfigId(accountConfigId);
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(entry.structure, [
|
|
87
|
+
{ depth: 0, contentType: "multipart/mixed" },
|
|
88
|
+
{ depth: 1, contentType: "text/plain" },
|
|
89
|
+
{ depth: 1, contentType: "application/pdf" },
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("carries the optional diagnostics as absent, not null", async () => {
|
|
94
|
+
const accountConfigId = randomId();
|
|
95
|
+
await seed({ accountConfigId });
|
|
96
|
+
|
|
97
|
+
const [entry] = await repo.listByAccountConfigId(accountConfigId);
|
|
98
|
+
|
|
99
|
+
// A folder nobody appointed a role to, a whole-body failure and an
|
|
100
|
+
// undeclared charset are all ordinary. The API contract makes them
|
|
101
|
+
// optional, so a null row value must not leak through as `null`.
|
|
102
|
+
assert.equal(entry.mailboxRole, undefined);
|
|
103
|
+
assert.equal(entry.failurePartPath, undefined);
|
|
104
|
+
assert.equal(entry.charset, undefined);
|
|
105
|
+
// The message-shape fields come off one optional BODYSTRUCTURE, so a
|
|
106
|
+
// message that failed before it was read carries none of them.
|
|
107
|
+
assert.equal(entry.contentType, undefined);
|
|
108
|
+
assert.equal(entry.transferEncoding, undefined);
|
|
109
|
+
assert.equal(entry.sizeBytes, undefined);
|
|
110
|
+
assert.equal(entry.messageIdHash, undefined);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IQuarantineRepository,
|
|
3
|
+
QuarantineItem,
|
|
4
|
+
QuarantineMimeNodeItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { desc, eq } from "drizzle-orm";
|
|
7
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
8
|
+
import { quarantineTable } from "../schema/quarantine.js";
|
|
9
|
+
|
|
10
|
+
type DB = NodePgDatabase<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
function rowToItem(row: typeof quarantineTable.$inferSelect): QuarantineItem {
|
|
13
|
+
return {
|
|
14
|
+
quarantineId: row.quarantineId,
|
|
15
|
+
accountConfigId: row.accountConfigId,
|
|
16
|
+
accountId: row.accountId,
|
|
17
|
+
mailboxId: row.mailboxId,
|
|
18
|
+
uidValidity: row.uidValidity,
|
|
19
|
+
uid: row.uid,
|
|
20
|
+
mailboxRole: row.mailboxRole ?? undefined,
|
|
21
|
+
mailboxPath: row.mailboxPath,
|
|
22
|
+
quarantinedAt: row.quarantinedAt,
|
|
23
|
+
attempts: row.attempts,
|
|
24
|
+
failureStage: row.failureStage,
|
|
25
|
+
failureCode: row.failureCode,
|
|
26
|
+
failureMessage: row.failureMessage,
|
|
27
|
+
failurePartPath: row.failurePartPath ?? undefined,
|
|
28
|
+
workerVersion: row.workerVersion,
|
|
29
|
+
contentType: row.contentType ?? undefined,
|
|
30
|
+
transferEncoding: row.transferEncoding ?? undefined,
|
|
31
|
+
charset: row.charset ?? undefined,
|
|
32
|
+
sizeBytes: row.sizeBytes ?? undefined,
|
|
33
|
+
structure: row.structure as QuarantineMimeNodeItem[],
|
|
34
|
+
messageIdHash: row.messageIdHash ?? undefined,
|
|
35
|
+
createdAt: row.createdAt,
|
|
36
|
+
updatedAt: row.updatedAt,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read side of the message quarantine (issue #72). The rows are written by the
|
|
42
|
+
* sync worker; nothing in the API process creates or clears one.
|
|
43
|
+
*/
|
|
44
|
+
export class QuarantineRepo implements IQuarantineRepository {
|
|
45
|
+
constructor(private db: DB) {}
|
|
46
|
+
|
|
47
|
+
listByAccountConfigId = async (
|
|
48
|
+
accountConfigId: string,
|
|
49
|
+
): Promise<QuarantineItem[]> => {
|
|
50
|
+
const rows = await this.db
|
|
51
|
+
.select()
|
|
52
|
+
.from(quarantineTable)
|
|
53
|
+
.where(eq(quarantineTable.accountConfigId, accountConfigId))
|
|
54
|
+
.orderBy(desc(quarantineTable.quarantinedAt));
|
|
55
|
+
return rows.map(rowToItem);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -382,6 +382,281 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
// ─── searchByDate: the unified listing's cross-folder search mode ─────────────
|
|
386
|
+
// The daily brief's unscoped search reaches every folder of every account in
|
|
387
|
+
// one query, so matching must span the caller-supplied mailbox scope rather
|
|
388
|
+
// than a single mailbox.
|
|
389
|
+
|
|
390
|
+
describe("DrizzleThreadMessageRepository.searchByDate", {
|
|
391
|
+
skip: !RUN_INTEG,
|
|
392
|
+
}, () => {
|
|
393
|
+
let repo: DrizzleThreadMessageRepository;
|
|
394
|
+
const cleanup: Array<() => Promise<void>> = [];
|
|
395
|
+
|
|
396
|
+
before(async () => {
|
|
397
|
+
await setupDb();
|
|
398
|
+
repo = new DrizzleThreadMessageRepository(PG_URL);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
after(async () => {
|
|
402
|
+
for (const fn of cleanup.reverse()) {
|
|
403
|
+
await fn();
|
|
404
|
+
}
|
|
405
|
+
await repo.close();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
async function seed(
|
|
409
|
+
accountConfigId: string,
|
|
410
|
+
mailboxId: string,
|
|
411
|
+
rows: Array<Partial<CreateThreadMessageInput>>,
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
for (const overrides of rows) {
|
|
414
|
+
const created = await repo.create(
|
|
415
|
+
makeInput(accountConfigId, mailboxId, overrides),
|
|
416
|
+
);
|
|
417
|
+
cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
test("matches across every mailbox in the scope, not just the inbox", async () => {
|
|
422
|
+
const acct = uuid();
|
|
423
|
+
const inbox = uuid();
|
|
424
|
+
const archive = uuid();
|
|
425
|
+
const spam = uuid();
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
await seed(acct, inbox, [
|
|
428
|
+
{ subject: "invoice inbox", sentDate: now, internalDate: now },
|
|
429
|
+
]);
|
|
430
|
+
await seed(acct, archive, [
|
|
431
|
+
{ subject: "invoice archive", sentDate: now - 1, internalDate: now - 1 },
|
|
432
|
+
]);
|
|
433
|
+
await seed(acct, spam, [
|
|
434
|
+
{ subject: "invoice spam", sentDate: now - 2, internalDate: now - 2 },
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
const result = await repo.searchByDate(
|
|
438
|
+
acct,
|
|
439
|
+
{ query: "invoice" },
|
|
440
|
+
{ excludeDeleted: true, mailboxIds: new Set([inbox, archive, spam]) },
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
assert.deepEqual(
|
|
444
|
+
result.items.map((r) => r.subject),
|
|
445
|
+
["invoice inbox", "invoice archive", "invoice spam"],
|
|
446
|
+
"newest first, across all three folders",
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("a mailbox outside the scope contributes nothing", async () => {
|
|
451
|
+
const acct = uuid();
|
|
452
|
+
const inbox = uuid();
|
|
453
|
+
const excluded = uuid();
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
await seed(acct, inbox, [
|
|
456
|
+
{ subject: "receipt kept", sentDate: now, internalDate: now },
|
|
457
|
+
]);
|
|
458
|
+
await seed(acct, excluded, [
|
|
459
|
+
{ subject: "receipt dropped", sentDate: now - 1, internalDate: now - 1 },
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
const result = await repo.searchByDate(
|
|
463
|
+
acct,
|
|
464
|
+
{ query: "receipt" },
|
|
465
|
+
{ excludeDeleted: true, mailboxIds: new Set([inbox]) },
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
assert.deepEqual(
|
|
469
|
+
result.items.map((r) => r.subject),
|
|
470
|
+
["receipt kept"],
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("every whitespace-separated term must match", async () => {
|
|
475
|
+
const acct = uuid();
|
|
476
|
+
const mbx = uuid();
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
await seed(acct, mbx, [
|
|
479
|
+
{
|
|
480
|
+
subject: "quarterly invoice",
|
|
481
|
+
fromEmail: "billing@acme.test",
|
|
482
|
+
sentDate: now,
|
|
483
|
+
internalDate: now,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
subject: "quarterly report",
|
|
487
|
+
fromEmail: "reports@acme.test",
|
|
488
|
+
sentDate: now - 1,
|
|
489
|
+
internalDate: now - 1,
|
|
490
|
+
},
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
const result = await repo.searchByDate(
|
|
494
|
+
acct,
|
|
495
|
+
{ query: "quarterly invoice" },
|
|
496
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
assert.deepEqual(
|
|
500
|
+
result.items.map((r) => r.subject),
|
|
501
|
+
["quarterly invoice"],
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("a term matches the From address as well as the subject", async () => {
|
|
506
|
+
const acct = uuid();
|
|
507
|
+
const mbx = uuid();
|
|
508
|
+
const now = Date.now();
|
|
509
|
+
await seed(acct, mbx, [
|
|
510
|
+
{
|
|
511
|
+
subject: "no keyword here",
|
|
512
|
+
fromEmail: "penelope@acme.test",
|
|
513
|
+
sentDate: now,
|
|
514
|
+
internalDate: now,
|
|
515
|
+
},
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
const result = await repo.searchByDate(
|
|
519
|
+
acct,
|
|
520
|
+
{ query: "penelope" },
|
|
521
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
assert.equal(result.items.length, 1);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("soft-deleted rows stay out", async () => {
|
|
528
|
+
const acct = uuid();
|
|
529
|
+
const mbx = uuid();
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
await seed(acct, mbx, [
|
|
532
|
+
{ subject: "parcel live", sentDate: now, internalDate: now },
|
|
533
|
+
{
|
|
534
|
+
subject: "parcel gone",
|
|
535
|
+
isDeleted: true,
|
|
536
|
+
sentDate: now - 1,
|
|
537
|
+
internalDate: now - 1,
|
|
538
|
+
},
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
const result = await repo.searchByDate(
|
|
542
|
+
acct,
|
|
543
|
+
{ query: "parcel" },
|
|
544
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
assert.deepEqual(
|
|
548
|
+
result.items.map((r) => r.subject),
|
|
549
|
+
["parcel live"],
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// A short page means the matches ran out, never that a read window did — the
|
|
554
|
+
// contract the endpoint documents for search mode.
|
|
555
|
+
test("pages over matches, and a full page yields a resumable cursor", async () => {
|
|
556
|
+
const acct = uuid();
|
|
557
|
+
const inbox = uuid();
|
|
558
|
+
const archive = uuid();
|
|
559
|
+
const now = Date.now();
|
|
560
|
+
await seed(acct, inbox, [
|
|
561
|
+
{ subject: "noise a", sentDate: now, internalDate: now },
|
|
562
|
+
{ subject: "gamma one", sentDate: now - 1, internalDate: now - 1 },
|
|
563
|
+
{ subject: "noise b", sentDate: now - 2, internalDate: now - 2 },
|
|
564
|
+
]);
|
|
565
|
+
await seed(acct, archive, [
|
|
566
|
+
{ subject: "gamma two", sentDate: now - 3, internalDate: now - 3 },
|
|
567
|
+
{ subject: "gamma three", sentDate: now - 4, internalDate: now - 4 },
|
|
568
|
+
]);
|
|
569
|
+
|
|
570
|
+
const scope = {
|
|
571
|
+
excludeDeleted: true,
|
|
572
|
+
mailboxIds: new Set([inbox, archive]),
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const page1 = await repo.searchByDate(
|
|
576
|
+
acct,
|
|
577
|
+
{ query: "gamma" },
|
|
578
|
+
{ ...scope, limit: 2 },
|
|
579
|
+
);
|
|
580
|
+
assert.deepEqual(
|
|
581
|
+
page1.items.map((r) => r.subject),
|
|
582
|
+
["gamma one", "gamma two"],
|
|
583
|
+
"a full page of matches, skipping the non-matching newer rows",
|
|
584
|
+
);
|
|
585
|
+
assert.ok(page1.continuationToken, "more matches remain — cursor expected");
|
|
586
|
+
|
|
587
|
+
const page2 = await repo.searchByDate(
|
|
588
|
+
acct,
|
|
589
|
+
{ query: "gamma" },
|
|
590
|
+
{ ...scope, limit: 2, continuationToken: page1.continuationToken },
|
|
591
|
+
);
|
|
592
|
+
assert.deepEqual(
|
|
593
|
+
page2.items.map((r) => r.subject),
|
|
594
|
+
["gamma three"],
|
|
595
|
+
"the last page is short because the matches ran out",
|
|
596
|
+
);
|
|
597
|
+
assert.equal(
|
|
598
|
+
page2.continuationToken,
|
|
599
|
+
undefined,
|
|
600
|
+
"a short page ends the pagination",
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("starred narrows the search without changing the scope", async () => {
|
|
605
|
+
const acct = uuid();
|
|
606
|
+
const archive = uuid();
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
await seed(acct, archive, [
|
|
609
|
+
{
|
|
610
|
+
subject: "delta starred",
|
|
611
|
+
hasStars: true,
|
|
612
|
+
sentDate: now,
|
|
613
|
+
internalDate: now,
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
subject: "delta plain",
|
|
617
|
+
hasStars: false,
|
|
618
|
+
sentDate: now - 1,
|
|
619
|
+
internalDate: now - 1,
|
|
620
|
+
},
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
const result = await repo.searchByDate(
|
|
624
|
+
acct,
|
|
625
|
+
{ query: "delta", starred: true },
|
|
626
|
+
{ excludeDeleted: true, mailboxIds: new Set([archive]) },
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
assert.deepEqual(
|
|
630
|
+
result.items.map((r) => r.subject),
|
|
631
|
+
["delta starred"],
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("another config's mail is never returned", async () => {
|
|
636
|
+
const mine = uuid();
|
|
637
|
+
const theirs = uuid();
|
|
638
|
+
const mbx = uuid();
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
await seed(mine, mbx, [
|
|
641
|
+
{ subject: "epsilon mine", sentDate: now, internalDate: now },
|
|
642
|
+
]);
|
|
643
|
+
await seed(theirs, mbx, [
|
|
644
|
+
{ subject: "epsilon theirs", sentDate: now - 1, internalDate: now - 1 },
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const result = await repo.searchByDate(
|
|
648
|
+
mine,
|
|
649
|
+
{ query: "epsilon" },
|
|
650
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
assert.deepEqual(
|
|
654
|
+
result.items.map((r) => r.subject),
|
|
655
|
+
["epsilon mine"],
|
|
656
|
+
);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
385
660
|
// ─── Native text-search semantics ─────────────────────────────────────────────
|
|
386
661
|
// The type-ahead search box lowercases the query before sending it. These tests
|
|
387
662
|
// pin the Postgres-native behaviour: case- and accent-insensitive substring
|
|
@@ -478,6 +478,65 @@ export class DrizzleThreadMessageRepository
|
|
|
478
478
|
};
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Cross-mailbox search for the unified listing's search mode. Same predicate
|
|
483
|
+
* builder and keyset cursor as `searchByMailboxWindow`, with the mailbox
|
|
484
|
+
* equality swapped for the caller's scope set. Matching runs in SQL over the
|
|
485
|
+
* whole scope, so a short page means the matches are exhausted.
|
|
486
|
+
*/
|
|
487
|
+
async searchByDate(
|
|
488
|
+
accountConfigId: string,
|
|
489
|
+
search: SearchOptions,
|
|
490
|
+
options?: {
|
|
491
|
+
order?: "asc" | "desc";
|
|
492
|
+
limit?: number;
|
|
493
|
+
continuationToken?: string;
|
|
494
|
+
mailboxIds?: Set<string>;
|
|
495
|
+
excludeDeleted?: boolean;
|
|
496
|
+
},
|
|
497
|
+
): Promise<ResultList<ThreadMessageItem>> {
|
|
498
|
+
const order = options?.order ?? "desc";
|
|
499
|
+
const limit = clampThreadSearchLimit(options?.limit);
|
|
500
|
+
const cursor = options?.continuationToken
|
|
501
|
+
? decodeDateCursor(options.continuationToken)
|
|
502
|
+
: null;
|
|
503
|
+
|
|
504
|
+
const mailboxCond = options?.mailboxIds?.size
|
|
505
|
+
? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
|
|
506
|
+
: undefined;
|
|
507
|
+
|
|
508
|
+
const rows = await this.db
|
|
509
|
+
.select()
|
|
510
|
+
.from(threadMessageTable)
|
|
511
|
+
.where(
|
|
512
|
+
and(
|
|
513
|
+
eq(threadMessageTable.accountConfigId, accountConfigId),
|
|
514
|
+
mailboxCond,
|
|
515
|
+
options?.excludeDeleted
|
|
516
|
+
? eq(threadMessageTable.isDeleted, false)
|
|
517
|
+
: undefined,
|
|
518
|
+
...buildSearchConditions(search),
|
|
519
|
+
sentDateCursorCond(order, cursor),
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
.orderBy(
|
|
523
|
+
order === "desc"
|
|
524
|
+
? desc(threadMessageTable.sentDate)
|
|
525
|
+
: asc(threadMessageTable.sentDate),
|
|
526
|
+
asc(threadMessageTable.threadMessageId),
|
|
527
|
+
)
|
|
528
|
+
.limit(limit);
|
|
529
|
+
|
|
530
|
+
const lastRow = rows[rows.length - 1];
|
|
531
|
+
return {
|
|
532
|
+
items: rows.map(toItem),
|
|
533
|
+
continuationToken:
|
|
534
|
+
rows.length === limit && lastRow
|
|
535
|
+
? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
|
|
536
|
+
: undefined,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
481
540
|
async listByStarred(
|
|
482
541
|
accountConfigId: string,
|
|
483
542
|
options?: {
|
package/src/schema.ts
CHANGED
|
@@ -25,4 +25,5 @@ export * from "./schema/i4-message-placement-move.js";
|
|
|
25
25
|
export * from "./schema/i4-organize-job-request.js";
|
|
26
26
|
export * from "./schema/i4-outbox-message.js";
|
|
27
27
|
export * from "./schema/message-data.js";
|
|
28
|
+
export * from "./schema/quarantine.js";
|
|
28
29
|
export { threadMessageTable } from "./schema/thread-message.js";
|