@remit/drizzle-service 0.0.31 → 0.0.33
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/repos/category-fixture.ts +156 -0
- package/src/repos/filter.test.ts +27 -4
- package/src/repos/filter.ts +48 -10
- package/src/repos/thread-message-category.test.ts +156 -0
- package/src/repos/thread-message.sqlite.test.ts +365 -1
- package/src/repos/thread-message.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The measured category shape of the owner's INBOX, shared by the sqlite and
|
|
5
|
+
* Postgres category suites (#304).
|
|
6
|
+
*
|
|
7
|
+
* It lives in one module because the shape is the load-bearing part of the
|
|
8
|
+
* regression: a filter resolved over the page the server returned is empty at
|
|
9
|
+
* every page size precisely because the rare categories sit outside the newest
|
|
10
|
+
* page. Two copies of those numbers can drift apart, and the one that drifts
|
|
11
|
+
* stops being the argument.
|
|
12
|
+
*
|
|
13
|
+
* Measured on the live instance (docs/architecture/mail-list-boundary.md).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type Category =
|
|
17
|
+
| "personal"
|
|
18
|
+
| "marketing"
|
|
19
|
+
| "automated"
|
|
20
|
+
| "newsletter"
|
|
21
|
+
| "transactional"
|
|
22
|
+
| "social";
|
|
23
|
+
|
|
24
|
+
export type CategoryTotals = Record<Category, number>;
|
|
25
|
+
|
|
26
|
+
/** INBOX as measured: 14,187 non-deleted rows. */
|
|
27
|
+
export const LIVE_TOTALS: CategoryTotals = {
|
|
28
|
+
personal: 4753,
|
|
29
|
+
marketing: 3942,
|
|
30
|
+
automated: 2680,
|
|
31
|
+
newsletter: 2295,
|
|
32
|
+
transactional: 429,
|
|
33
|
+
social: 88,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The same shape with the four common categories trimmed, for the Postgres twin.
|
|
38
|
+
*
|
|
39
|
+
* The rare tail is untouched — `social` stays at its measured 88 — so every
|
|
40
|
+
* assertion the suites make about a rare category holds identically. What
|
|
41
|
+
* shrinks is the bulk that only exists to make the common categories common,
|
|
42
|
+
* which the Postgres suite does not assert on and which costs ~20s of seeding.
|
|
43
|
+
*/
|
|
44
|
+
export const TRIMMED_TOTALS: CategoryTotals = {
|
|
45
|
+
personal: 700,
|
|
46
|
+
marketing: 500,
|
|
47
|
+
automated: 400,
|
|
48
|
+
newsletter: 200,
|
|
49
|
+
transactional: 130,
|
|
50
|
+
social: 88,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The newest 100 rows of that INBOX, as measured. Two of the 4,753 personal
|
|
55
|
+
* messages and two of the 88 social ones are in it, which is why selecting
|
|
56
|
+
* either chip over the newest page returns almost nothing.
|
|
57
|
+
*/
|
|
58
|
+
export const NEWEST_100: CategoryTotals = {
|
|
59
|
+
automated: 77,
|
|
60
|
+
newsletter: 14,
|
|
61
|
+
marketing: 3,
|
|
62
|
+
social: 2,
|
|
63
|
+
transactional: 2,
|
|
64
|
+
personal: 2,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const BASE_DATE = 1_767_225_600_000;
|
|
68
|
+
|
|
69
|
+
export const totalRows = (totals: CategoryTotals): number =>
|
|
70
|
+
Object.values(totals).reduce((sum, count) => sum + count, 0);
|
|
71
|
+
|
|
72
|
+
// mulberry32: a real 32-bit generator, so the stream is the one the shuffle
|
|
73
|
+
// claims. The interleaving is part of the fixture — a failure has to be
|
|
74
|
+
// reproducible — so this is seeded rather than Math.random.
|
|
75
|
+
const mulberry32 = (seed: number): (() => number) => {
|
|
76
|
+
let state = seed >>> 0;
|
|
77
|
+
return () => {
|
|
78
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
79
|
+
let t = state;
|
|
80
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
81
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
82
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const shuffled = (values: Category[], seed: number): Category[] => {
|
|
87
|
+
const random = mulberry32(seed);
|
|
88
|
+
const out = [...values];
|
|
89
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
90
|
+
const j = Math.floor(random() * (i + 1));
|
|
91
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const expand = (counts: Partial<CategoryTotals>): Category[] => {
|
|
97
|
+
const out: Category[] = [];
|
|
98
|
+
for (const [category, count] of Object.entries(counts) as Array<
|
|
99
|
+
[Category, number]
|
|
100
|
+
>) {
|
|
101
|
+
for (let i = 0; i < count; i++) out.push(category);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type FixtureRow = Omit<ThreadMessageItem, "star"> & { star: "none" };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One mailbox of thread-message rows in the given category distribution,
|
|
110
|
+
* newest first, with the measured mix in the newest 100.
|
|
111
|
+
*
|
|
112
|
+
* Every seventh row repeats its predecessor's `sentDate`, so a keyset walk over
|
|
113
|
+
* the fixture crosses tie groups instead of a strictly distinct sequence.
|
|
114
|
+
*/
|
|
115
|
+
export const categoryFixtureRows = (options: {
|
|
116
|
+
totals: CategoryTotals;
|
|
117
|
+
accountConfigId: string;
|
|
118
|
+
mailboxId: string;
|
|
119
|
+
}): FixtureRow[] => {
|
|
120
|
+
const { totals, accountConfigId, mailboxId } = options;
|
|
121
|
+
const tail = Object.fromEntries(
|
|
122
|
+
(Object.keys(totals) as Category[]).map((category) => [
|
|
123
|
+
category,
|
|
124
|
+
totals[category] - NEWEST_100[category],
|
|
125
|
+
]),
|
|
126
|
+
) as CategoryTotals;
|
|
127
|
+
|
|
128
|
+
const byRecency = [
|
|
129
|
+
...shuffled(expand(NEWEST_100), 11),
|
|
130
|
+
...shuffled(expand(tail), 29),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
return byRecency.map((category, index) => {
|
|
134
|
+
const step = index - (index % 7 === 0 && index > 0 ? 1 : 0);
|
|
135
|
+
const sentDate = BASE_DATE - step * 1000;
|
|
136
|
+
return {
|
|
137
|
+
threadMessageId: `tm-category-${index}`,
|
|
138
|
+
accountConfigId,
|
|
139
|
+
threadId: `t-category-${index}`,
|
|
140
|
+
messageId: `m-category-${index}`,
|
|
141
|
+
mailboxId,
|
|
142
|
+
uid: index + 1,
|
|
143
|
+
referenceOrder: 0,
|
|
144
|
+
internalDate: sentDate,
|
|
145
|
+
sentDate,
|
|
146
|
+
isRead: false,
|
|
147
|
+
hasAttachment: false,
|
|
148
|
+
star: "none",
|
|
149
|
+
hasStars: false,
|
|
150
|
+
isDeleted: false,
|
|
151
|
+
category,
|
|
152
|
+
createdAt: 0,
|
|
153
|
+
updatedAt: 0,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
};
|
package/src/repos/filter.test.ts
CHANGED
|
@@ -36,6 +36,10 @@ describe("FilterRepo", () => {
|
|
|
36
36
|
filter.ruleChangedAt > 0,
|
|
37
37
|
"ruleChangedAt should be set on create",
|
|
38
38
|
);
|
|
39
|
+
assert.ok(
|
|
40
|
+
filter.actionChangedAt > 0,
|
|
41
|
+
"actionChangedAt should be set on create",
|
|
42
|
+
);
|
|
39
43
|
assert.equal(filter.expiresAt, undefined);
|
|
40
44
|
assert.equal(filter.ttl, undefined);
|
|
41
45
|
});
|
|
@@ -97,9 +101,14 @@ describe("FilterRepo", () => {
|
|
|
97
101
|
filter.ruleChangedAt,
|
|
98
102
|
"a name-only edit must not move ruleChangedAt (RFC 034 Decision 3.2)",
|
|
99
103
|
);
|
|
104
|
+
assert.equal(
|
|
105
|
+
renamed.actionChangedAt,
|
|
106
|
+
filter.actionChangedAt,
|
|
107
|
+
"a name-only edit must not move actionChangedAt either",
|
|
108
|
+
);
|
|
100
109
|
});
|
|
101
110
|
|
|
102
|
-
test("update bumps ruleChangedAt when the action changes", async () => {
|
|
111
|
+
test("update bumps both ruleChangedAt and actionChangedAt when the action changes", async () => {
|
|
103
112
|
const accountConfigId = randomId();
|
|
104
113
|
const filter = await repo.create({
|
|
105
114
|
accountConfigId,
|
|
@@ -117,9 +126,13 @@ describe("FilterRepo", () => {
|
|
|
117
126
|
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
118
127
|
"an action edit must move ruleChangedAt forward",
|
|
119
128
|
);
|
|
129
|
+
assert.ok(
|
|
130
|
+
updated.actionChangedAt > filter.actionChangedAt,
|
|
131
|
+
"an action edit must move actionChangedAt forward too — it is what a matching message's exclusive move goes by",
|
|
132
|
+
);
|
|
120
133
|
});
|
|
121
134
|
|
|
122
|
-
test("update bumps ruleChangedAt when scope changes (reader #266)", async () => {
|
|
135
|
+
test("update bumps ruleChangedAt but NOT actionChangedAt when scope changes (reader #266, #384)", async () => {
|
|
123
136
|
const accountConfigId = randomId();
|
|
124
137
|
const filter = await repo.create({
|
|
125
138
|
accountConfigId,
|
|
@@ -141,11 +154,16 @@ describe("FilterRepo", () => {
|
|
|
141
154
|
assert.equal(updated.expiresAt, expiresAt);
|
|
142
155
|
assert.ok(
|
|
143
156
|
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
144
|
-
"a scope edit must move ruleChangedAt forward",
|
|
157
|
+
"a scope edit must move ruleChangedAt forward, so a lapsed filter's back-application can be offered again",
|
|
158
|
+
);
|
|
159
|
+
assert.equal(
|
|
160
|
+
updated.actionChangedAt,
|
|
161
|
+
filter.actionChangedAt,
|
|
162
|
+
"a scope edit changes the filter's lifecycle, not what it matches or does — it must not move actionChangedAt, or it would wrongly promote the filter to move-winner",
|
|
145
163
|
);
|
|
146
164
|
});
|
|
147
165
|
|
|
148
|
-
test("update bumps ruleChangedAt when only expiresAt changes (reader #266)", async () => {
|
|
166
|
+
test("update bumps ruleChangedAt but NOT actionChangedAt when only expiresAt changes (reader #266, #384)", async () => {
|
|
149
167
|
const accountConfigId = randomId();
|
|
150
168
|
const initialExpiresAt = "2026-08-01T00:00:00+00:00";
|
|
151
169
|
const filter = await repo.create({
|
|
@@ -169,6 +187,11 @@ describe("FilterRepo", () => {
|
|
|
169
187
|
updated.ruleChangedAt > filter.ruleChangedAt,
|
|
170
188
|
"an expiresAt edit must move ruleChangedAt forward",
|
|
171
189
|
);
|
|
190
|
+
assert.equal(
|
|
191
|
+
updated.actionChangedAt,
|
|
192
|
+
filter.actionChangedAt,
|
|
193
|
+
"extending a filter's expiry alone must not promote it to move-winner (reader #384) — actionChangedAt must not move",
|
|
194
|
+
);
|
|
172
195
|
});
|
|
173
196
|
|
|
174
197
|
test("update to Standing clears expiresAt and ttl at the storage layer, even if the caller didn't null them (reader #266)", async () => {
|
package/src/repos/filter.ts
CHANGED
|
@@ -29,6 +29,20 @@ const RULE_ASSERTION_FIELDS = [
|
|
|
29
29
|
"expiresAt",
|
|
30
30
|
] as const satisfies readonly (keyof UpdateFilterInput)[];
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The subset of {@link RULE_ASSERTION_FIELDS} that changes what a filter
|
|
34
|
+
* matches or what it does — excluding `scope`/`expiresAt`, which only change
|
|
35
|
+
* how long the filter runs. Backs `actionChangedAt` (reader #384), the
|
|
36
|
+
* narrower signal `selectMoveWinner` reads for exclusive-move precedence.
|
|
37
|
+
*/
|
|
38
|
+
const ACTION_ASSERTION_FIELDS = [
|
|
39
|
+
"hasAnchor",
|
|
40
|
+
"matchOperator",
|
|
41
|
+
"literalClauses",
|
|
42
|
+
"actionLabelId",
|
|
43
|
+
"actionMailboxId",
|
|
44
|
+
] as const satisfies readonly (keyof UpdateFilterInput)[];
|
|
45
|
+
|
|
32
46
|
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
33
47
|
|
|
34
48
|
/**
|
|
@@ -38,10 +52,24 @@ const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
|
38
52
|
* alongside the predicate/action: re-asserting how long a filter runs is the
|
|
39
53
|
* same kind of "the user just told Remit something new" moment, and it is
|
|
40
54
|
* what lets a lapsed filter's back-application be offered again.
|
|
55
|
+
*
|
|
56
|
+
* This is deliberately broader than {@link changesActionAssertion} — it backs
|
|
57
|
+
* `ruleChangedAt`, the settings UI's back-apply-offer signal, not exclusive-
|
|
58
|
+
* move precedence (reader #384).
|
|
41
59
|
*/
|
|
42
60
|
const changesRuleAssertion = (input: UpdateFilterInput): boolean =>
|
|
43
61
|
RULE_ASSERTION_FIELDS.some((field) => field in input);
|
|
44
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Whether `input` touches the predicate or the action — never `scope` or
|
|
65
|
+
* `expiresAt` (reader #384). Backs `actionChangedAt`, which `selectMoveWinner`
|
|
66
|
+
* reads to decide an exclusive-move conflict: extending a filter's expiry or
|
|
67
|
+
* flipping Standing/Temporary changes its lifecycle, not where a matching
|
|
68
|
+
* message goes, so it must not promote the filter to move-winner.
|
|
69
|
+
*/
|
|
70
|
+
const changesActionAssertion = (input: UpdateFilterInput): boolean =>
|
|
71
|
+
ACTION_ASSERTION_FIELDS.some((field) => field in input);
|
|
72
|
+
|
|
45
73
|
function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
|
|
46
74
|
return {
|
|
47
75
|
filterId: row.filterId,
|
|
@@ -53,6 +81,7 @@ function rowToFilter(row: typeof filterTable.$inferSelect): FilterItem {
|
|
|
53
81
|
state: row.state,
|
|
54
82
|
hasAnchor: row.hasAnchor,
|
|
55
83
|
ruleChangedAt: row.ruleChangedAt,
|
|
84
|
+
actionChangedAt: row.actionChangedAt,
|
|
56
85
|
matchOperator: row.matchOperator,
|
|
57
86
|
literalClauses: row.literalClauses as FilterItem["literalClauses"],
|
|
58
87
|
actionLabelId: row.actionLabelId,
|
|
@@ -66,12 +95,13 @@ export class FilterRepo implements IFilterRepository {
|
|
|
66
95
|
constructor(private db: DB) {}
|
|
67
96
|
|
|
68
97
|
/**
|
|
69
|
-
* `ruleChangedAt`
|
|
70
|
-
* predicate/action
|
|
71
|
-
* 3.2).
|
|
98
|
+
* `ruleChangedAt` and `actionChangedAt` are both set to the same instant as
|
|
99
|
+
* creation: a new filter's predicate/action (and its scope/expiry) are, by
|
|
100
|
+
* definition, freshly asserted (RFC 034 Decision 3.2).
|
|
72
101
|
*/
|
|
73
102
|
async create(input: CreateFilterInput): Promise<FilterItem> {
|
|
74
103
|
const now = Date.now();
|
|
104
|
+
const createdRuleChangedAt = nowSeconds();
|
|
75
105
|
const [row] = await this.db
|
|
76
106
|
.insert(filterTable)
|
|
77
107
|
.values({
|
|
@@ -83,7 +113,8 @@ export class FilterRepo implements IFilterRepository {
|
|
|
83
113
|
ttl: input.ttl ?? null,
|
|
84
114
|
state: input.state ?? FilterState.Active,
|
|
85
115
|
hasAnchor: input.hasAnchor ?? false,
|
|
86
|
-
ruleChangedAt:
|
|
116
|
+
ruleChangedAt: createdRuleChangedAt,
|
|
117
|
+
actionChangedAt: createdRuleChangedAt,
|
|
87
118
|
matchOperator: input.matchOperator ?? FilterMatchOperator.And,
|
|
88
119
|
literalClauses: input.literalClauses ?? [],
|
|
89
120
|
actionLabelId: input.actionLabelId ?? "None",
|
|
@@ -112,9 +143,13 @@ export class FilterRepo implements IFilterRepository {
|
|
|
112
143
|
}
|
|
113
144
|
|
|
114
145
|
/**
|
|
115
|
-
* `ruleChangedAt`
|
|
116
|
-
*
|
|
117
|
-
*
|
|
146
|
+
* `ruleChangedAt` advances when `input` touches the predicate, the action,
|
|
147
|
+
* the scope, or the expiry — never on a cosmetic `name` edit (RFC 034
|
|
148
|
+
* Decision 3.2, reader #266). `actionChangedAt` advances only for the
|
|
149
|
+
* narrower predicate/action subset (reader #384): a scope/expiresAt-only
|
|
150
|
+
* edit bumps `ruleChangedAt` (so a lapsed filter's back-application can be
|
|
151
|
+
* offered again) without touching `actionChangedAt`, so it cannot flip
|
|
152
|
+
* `selectMoveWinner`'s exclusive-move precedence.
|
|
118
153
|
*
|
|
119
154
|
* A patch moving `scope` to `Standing` clears `expiresAt`/`ttl` at the SQL
|
|
120
155
|
* layer regardless of what `input` carries for them: the caller (the
|
|
@@ -130,9 +165,12 @@ export class FilterRepo implements IFilterRepository {
|
|
|
130
165
|
filterId: string,
|
|
131
166
|
input: UpdateFilterInput,
|
|
132
167
|
): Promise<FilterItem> {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
168
|
+
const changedAt = nowSeconds();
|
|
169
|
+
const patch = {
|
|
170
|
+
...input,
|
|
171
|
+
...(changesRuleAssertion(input) ? { ruleChangedAt: changedAt } : {}),
|
|
172
|
+
...(changesActionAssertion(input) ? { actionChangedAt: changedAt } : {}),
|
|
173
|
+
};
|
|
136
174
|
const updates: Partial<typeof filterTable.$inferInsert> = {
|
|
137
175
|
...patch,
|
|
138
176
|
updatedAt: Date.now(),
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
4
|
+
import type pg from "pg";
|
|
5
|
+
import type { Db } from "../db.js";
|
|
6
|
+
import { threadMessageTable } from "../schema/thread-message.js";
|
|
7
|
+
import * as schema from "../schema.js";
|
|
8
|
+
import { createTestDb } from "../test-db.js";
|
|
9
|
+
import { categoryFixtureRows, TRIMMED_TOTALS } from "./category-fixture.js";
|
|
10
|
+
import { DrizzleThreadMessageRepository } from "./thread-message.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Postgres half of the category-predicate guard (#304). The behavioural
|
|
14
|
+
* coverage is in thread-message.sqlite.test.ts against the full measured shape;
|
|
15
|
+
* what this file exists for is the query plan, which is dialect-specific:
|
|
16
|
+
* `EXPLAIN QUERY PLAN` is SQLite syntax and Postgres needs its own `EXPLAIN`.
|
|
17
|
+
*
|
|
18
|
+
* It runs against the embedded Postgres harness, which pushes the generated
|
|
19
|
+
* drizzle schema, so `tm_by_mailbox_category_date` is present here for the same
|
|
20
|
+
* reason it is present in the sqlite harness: the index is declared in TypeSpec
|
|
21
|
+
* and emitted into the schema both dialects derive from. That is what makes the
|
|
22
|
+
* assertion a real guard rather than a restatement of the predicate — remove the
|
|
23
|
+
* index from the schema and it fails.
|
|
24
|
+
*
|
|
25
|
+
* The fixture is the measured shape with its common categories trimmed
|
|
26
|
+
* (TRIMMED_TOTALS): the rare tail the assertions use is untouched, and the
|
|
27
|
+
* planner needs only enough rows to prefer an index over a sequential scan. It
|
|
28
|
+
* also needs statistics before it will, so the fixture is ANALYZEd; a production
|
|
29
|
+
* instance gets that from autovacuum.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const ACCOUNT = "acct-category-pg";
|
|
33
|
+
const MAILBOX = "mbx-category-pg";
|
|
34
|
+
|
|
35
|
+
describe("thread-message category predicate (postgres)", () => {
|
|
36
|
+
let pool: pg.Pool;
|
|
37
|
+
let close: () => Promise<void>;
|
|
38
|
+
let repo: DrizzleThreadMessageRepository;
|
|
39
|
+
const logged: Array<{ text: string; params: unknown[] }> = [];
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
({ pool, close } = await createTestDb());
|
|
43
|
+
|
|
44
|
+
const db = drizzle(pool, {
|
|
45
|
+
schema,
|
|
46
|
+
logger: {
|
|
47
|
+
logQuery(text: string, params: unknown[]) {
|
|
48
|
+
logged.push({ text, params });
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}) as unknown as Db<Record<string, unknown>>;
|
|
52
|
+
repo = new DrizzleThreadMessageRepository(db);
|
|
53
|
+
|
|
54
|
+
const rows = categoryFixtureRows({
|
|
55
|
+
totals: TRIMMED_TOTALS,
|
|
56
|
+
accountConfigId: ACCOUNT,
|
|
57
|
+
mailboxId: MAILBOX,
|
|
58
|
+
});
|
|
59
|
+
const insertDb = drizzle(pool, { schema });
|
|
60
|
+
for (let i = 0; i < rows.length; i += 500) {
|
|
61
|
+
await insertDb.insert(threadMessageTable).values(rows.slice(i, i + 500));
|
|
62
|
+
}
|
|
63
|
+
await pool.query("ANALYZE thread_message");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
after(async () => {
|
|
67
|
+
await close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a filtered page is a full page of matches, however far back they sit", async () => {
|
|
71
|
+
const social = await repo.searchByMailboxWindow(
|
|
72
|
+
ACCOUNT,
|
|
73
|
+
MAILBOX,
|
|
74
|
+
{ category: ["social"] },
|
|
75
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
76
|
+
);
|
|
77
|
+
assert.equal(social.items.length, 50);
|
|
78
|
+
assert.ok(social.items.every((item) => item.category === "social"));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a continuation token walks the whole match set without repeats or gaps", async () => {
|
|
82
|
+
const seen: string[] = [];
|
|
83
|
+
let continuationToken: string | undefined;
|
|
84
|
+
do {
|
|
85
|
+
const page = await repo.searchByMailboxWindow(
|
|
86
|
+
ACCOUNT,
|
|
87
|
+
MAILBOX,
|
|
88
|
+
{ category: ["social"] },
|
|
89
|
+
{
|
|
90
|
+
limit: 25,
|
|
91
|
+
order: "desc",
|
|
92
|
+
excludeDeleted: true,
|
|
93
|
+
continuationToken,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
seen.push(...page.items.map((item) => item.threadMessageId));
|
|
97
|
+
continuationToken = page.continuationToken;
|
|
98
|
+
} while (continuationToken);
|
|
99
|
+
assert.equal(seen.length, TRIMMED_TOTALS.social);
|
|
100
|
+
assert.equal(new Set(seen).size, TRIMMED_TOTALS.social);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("query plan", () => {
|
|
104
|
+
// Each assertion names the category column as well as the index: both
|
|
105
|
+
// engines can reach this index on its (account_config_id, mailbox_id)
|
|
106
|
+
// prefix alone, so an index-only assertion would still pass with the
|
|
107
|
+
// predicate removed.
|
|
108
|
+
const assertServedByIndex = async (
|
|
109
|
+
run: () => Promise<unknown>,
|
|
110
|
+
): Promise<void> => {
|
|
111
|
+
logged.length = 0;
|
|
112
|
+
await run();
|
|
113
|
+
const selects = logged.filter((entry) => /^\s*select/i.test(entry.text));
|
|
114
|
+
assert.ok(selects.length > 0, "the repo issued a select");
|
|
115
|
+
const plan: string[] = [];
|
|
116
|
+
for (const entry of selects) {
|
|
117
|
+
const explained = await pool.query<{ "QUERY PLAN": string }>({
|
|
118
|
+
text: `EXPLAIN ${entry.text}`,
|
|
119
|
+
values: entry.params,
|
|
120
|
+
});
|
|
121
|
+
plan.push(...explained.rows.map((row) => row["QUERY PLAN"]));
|
|
122
|
+
}
|
|
123
|
+
const text = plan.join("\n");
|
|
124
|
+
assert.ok(
|
|
125
|
+
text.includes("tm_by_mailbox_category_date"),
|
|
126
|
+
`no plan node used the index:\n${text}`,
|
|
127
|
+
);
|
|
128
|
+
assert.ok(
|
|
129
|
+
/Index Cond:[^\n]*category/.test(text),
|
|
130
|
+
`the index was used but not on category:\n${text}`,
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
test("the filtered window is served by tm_by_mailbox_category_date", async () => {
|
|
135
|
+
await assertServedByIndex(() =>
|
|
136
|
+
repo.searchByMailboxWindow(
|
|
137
|
+
ACCOUNT,
|
|
138
|
+
MAILBOX,
|
|
139
|
+
{ category: ["social"] },
|
|
140
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("the filtered count is served by tm_by_mailbox_category_date", async () => {
|
|
146
|
+
await assertServedByIndex(() =>
|
|
147
|
+
repo.countByMailbox(
|
|
148
|
+
ACCOUNT,
|
|
149
|
+
MAILBOX,
|
|
150
|
+
{ category: ["social"] },
|
|
151
|
+
{ excludeDeleted: true },
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -3,6 +3,13 @@ import { after, before, describe, test } from "node:test";
|
|
|
3
3
|
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
4
4
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
5
5
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
|
+
import {
|
|
7
|
+
BASE_DATE,
|
|
8
|
+
type Category,
|
|
9
|
+
categoryFixtureRows,
|
|
10
|
+
LIVE_TOTALS,
|
|
11
|
+
totalRows,
|
|
12
|
+
} from "./category-fixture.js";
|
|
6
13
|
import { DrizzleThreadMessageRepository } from "./thread-message.js";
|
|
7
14
|
|
|
8
15
|
// The thread-message repo on sqlite (RFC 036 D1): CRUD, keyset pagination, and
|
|
@@ -36,11 +43,12 @@ function makeInput(
|
|
|
36
43
|
|
|
37
44
|
describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
38
45
|
let db: Awaited<ReturnType<typeof createSqliteTestDb>>["db"];
|
|
46
|
+
let sqlite: Awaited<ReturnType<typeof createSqliteTestDb>>["sqlite"];
|
|
39
47
|
let close: () => Promise<void>;
|
|
40
48
|
let repo: DrizzleThreadMessageRepository;
|
|
41
49
|
|
|
42
50
|
before(async () => {
|
|
43
|
-
({ db, close } = await createSqliteTestDb(
|
|
51
|
+
({ db, sqlite, close } = await createSqliteTestDb(
|
|
44
52
|
{
|
|
45
53
|
threadMessage: threadMessageTable,
|
|
46
54
|
},
|
|
@@ -608,4 +616,360 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
|
|
|
608
616
|
);
|
|
609
617
|
});
|
|
610
618
|
});
|
|
619
|
+
|
|
620
|
+
// ─── category as a SQL predicate (#304) ───────────────────────────────────
|
|
621
|
+
//
|
|
622
|
+
// The shape is the owner's instance, not a convenience fixture — see
|
|
623
|
+
// ./category-fixture.ts. A filter applied to the page the server happens to
|
|
624
|
+
// return is empty or near-empty on that shape whatever the page size, which
|
|
625
|
+
// is the reported bug.
|
|
626
|
+
describe("category filter over the whole mailbox", () => {
|
|
627
|
+
const CAT_ACCOUNT = "acct-category";
|
|
628
|
+
const CAT_MAILBOX = "mbx-category";
|
|
629
|
+
|
|
630
|
+
before(async () => {
|
|
631
|
+
const rows = categoryFixtureRows({
|
|
632
|
+
totals: LIVE_TOTALS,
|
|
633
|
+
accountConfigId: CAT_ACCOUNT,
|
|
634
|
+
mailboxId: CAT_MAILBOX,
|
|
635
|
+
});
|
|
636
|
+
assert.equal(rows.length, totalRows(LIVE_TOTALS));
|
|
637
|
+
for (let i = 0; i < rows.length; i += 500) {
|
|
638
|
+
await db.insert(threadMessageTable).values(rows.slice(i, i + 500));
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const walk = async (
|
|
643
|
+
category: Category[],
|
|
644
|
+
pageSize: number,
|
|
645
|
+
): Promise<string[]> => {
|
|
646
|
+
const seen: string[] = [];
|
|
647
|
+
let continuationToken: string | undefined;
|
|
648
|
+
do {
|
|
649
|
+
const page = await repo.searchByMailboxWindow(
|
|
650
|
+
CAT_ACCOUNT,
|
|
651
|
+
CAT_MAILBOX,
|
|
652
|
+
{ category },
|
|
653
|
+
{
|
|
654
|
+
limit: pageSize,
|
|
655
|
+
order: "desc",
|
|
656
|
+
excludeDeleted: true,
|
|
657
|
+
continuationToken,
|
|
658
|
+
},
|
|
659
|
+
);
|
|
660
|
+
seen.push(...page.items.map((item) => item.threadMessageId));
|
|
661
|
+
continuationToken = page.continuationToken;
|
|
662
|
+
} while (continuationToken);
|
|
663
|
+
return seen;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
test("the newest page holds almost none of the rare categories", async () => {
|
|
667
|
+
const newest = await repo.searchByMailboxWindow(
|
|
668
|
+
CAT_ACCOUNT,
|
|
669
|
+
CAT_MAILBOX,
|
|
670
|
+
{},
|
|
671
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
672
|
+
);
|
|
673
|
+
assert.equal(newest.items.length, 50);
|
|
674
|
+
const count = (category: Category) =>
|
|
675
|
+
newest.items.filter((item) => item.category === category).length;
|
|
676
|
+
assert.ok(
|
|
677
|
+
count("social") <= 2,
|
|
678
|
+
"at most 2 social rows in the newest page",
|
|
679
|
+
);
|
|
680
|
+
assert.ok(
|
|
681
|
+
count("personal") <= 2,
|
|
682
|
+
"at most 2 personal rows in the newest page",
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// The regression the epic turns on. A filter resolved over the returned
|
|
687
|
+
// page cannot produce this answer: on this mailbox the newest 50 rows hold
|
|
688
|
+
// two personal messages, so the old path returned two rows (or none, for
|
|
689
|
+
// social) with a continuation token attached.
|
|
690
|
+
test("a filtered page is a full page of matches, however far back they sit", async () => {
|
|
691
|
+
const personal = await repo.searchByMailboxWindow(
|
|
692
|
+
CAT_ACCOUNT,
|
|
693
|
+
CAT_MAILBOX,
|
|
694
|
+
{ category: ["personal"] },
|
|
695
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
696
|
+
);
|
|
697
|
+
assert.equal(personal.items.length, 50);
|
|
698
|
+
assert.ok(
|
|
699
|
+
personal.items.every((item) => item.category === "personal"),
|
|
700
|
+
"every row on the page matches the filter",
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
const social = await repo.searchByMailboxWindow(
|
|
704
|
+
CAT_ACCOUNT,
|
|
705
|
+
CAT_MAILBOX,
|
|
706
|
+
{ category: ["social"] },
|
|
707
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
708
|
+
);
|
|
709
|
+
assert.equal(social.items.length, 50);
|
|
710
|
+
assert.ok(social.items.every((item) => item.category === "social"));
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("the filtered page keeps newest-first order and the id tiebreak", async () => {
|
|
714
|
+
const page = await repo.searchByMailboxWindow(
|
|
715
|
+
CAT_ACCOUNT,
|
|
716
|
+
CAT_MAILBOX,
|
|
717
|
+
{ category: ["social"] },
|
|
718
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
719
|
+
);
|
|
720
|
+
for (let i = 1; i < page.items.length; i++) {
|
|
721
|
+
const previous = page.items[i - 1];
|
|
722
|
+
const current = page.items[i];
|
|
723
|
+
assert.ok(
|
|
724
|
+
previous.sentDate > current.sentDate ||
|
|
725
|
+
(previous.sentDate === current.sentDate &&
|
|
726
|
+
previous.threadMessageId < current.threadMessageId),
|
|
727
|
+
"rows descend by sentDate, ascending by id inside a tie group",
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("a continuation token walks the whole match set without repeats or gaps", async () => {
|
|
733
|
+
const seen = await walk(["social"], 25);
|
|
734
|
+
assert.equal(seen.length, LIVE_TOTALS.social);
|
|
735
|
+
assert.equal(new Set(seen).size, LIVE_TOTALS.social);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("multiple categories behave as a union", async () => {
|
|
739
|
+
const page = await repo.searchByMailboxWindow(
|
|
740
|
+
CAT_ACCOUNT,
|
|
741
|
+
CAT_MAILBOX,
|
|
742
|
+
{ category: ["social", "transactional"] },
|
|
743
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
744
|
+
);
|
|
745
|
+
assert.equal(page.items.length, 50);
|
|
746
|
+
assert.ok(
|
|
747
|
+
page.items.every(
|
|
748
|
+
(item) =>
|
|
749
|
+
item.category === "social" || item.category === "transactional",
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
const seen = await walk(["social", "transactional"], 200);
|
|
754
|
+
assert.equal(seen.length, LIVE_TOTALS.social + LIVE_TOTALS.transactional);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// No `limit`: countByMailbox still clamps its answer to the clamped limit,
|
|
758
|
+
// which is what #305 changes. What this asserts is the predicate — the
|
|
759
|
+
// count is over the mailbox rather than over a page of it.
|
|
760
|
+
test("countByMailbox counts the matches in the mailbox, not the page", async () => {
|
|
761
|
+
assert.equal(
|
|
762
|
+
await repo.countByMailbox(
|
|
763
|
+
CAT_ACCOUNT,
|
|
764
|
+
CAT_MAILBOX,
|
|
765
|
+
{ category: ["social"] },
|
|
766
|
+
{ excludeDeleted: true },
|
|
767
|
+
),
|
|
768
|
+
LIVE_TOTALS.social,
|
|
769
|
+
);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("the category predicate composes with the other filters", async () => {
|
|
773
|
+
const page = await repo.searchByMailboxWindow(
|
|
774
|
+
CAT_ACCOUNT,
|
|
775
|
+
CAT_MAILBOX,
|
|
776
|
+
{ category: ["social"], unread: true },
|
|
777
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
778
|
+
);
|
|
779
|
+
assert.equal(page.items.length, 50);
|
|
780
|
+
assert.ok(
|
|
781
|
+
page.items.every(
|
|
782
|
+
(item) => item.category === "social" && item.isRead === false,
|
|
783
|
+
),
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const none = await repo.searchByMailboxWindow(
|
|
787
|
+
CAT_ACCOUNT,
|
|
788
|
+
CAT_MAILBOX,
|
|
789
|
+
{ category: ["social"], starred: true },
|
|
790
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
791
|
+
);
|
|
792
|
+
assert.equal(none.items.length, 0);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("an empty category set is not a filter", async () => {
|
|
796
|
+
const page = await repo.searchByMailboxWindow(
|
|
797
|
+
CAT_ACCOUNT,
|
|
798
|
+
CAT_MAILBOX,
|
|
799
|
+
{ category: [] },
|
|
800
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
801
|
+
);
|
|
802
|
+
assert.equal(page.items.length, 50);
|
|
803
|
+
assert.ok(
|
|
804
|
+
page.items.some((item) => item.category !== page.items[0]?.category),
|
|
805
|
+
"the page is unfiltered, so it holds more than one category",
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// #45: `uncategorized` is the not-yet-classified state as a named value.
|
|
810
|
+
// Folding it into `personal` made a classification gap read as a large
|
|
811
|
+
// personal inbox, so the filter has to be able to ask for it and must
|
|
812
|
+
// never answer with the other.
|
|
813
|
+
test("uncategorized is its own filterable value", async () => {
|
|
814
|
+
const account = "acct-category-default";
|
|
815
|
+
const mailbox = "mbx-category-default";
|
|
816
|
+
const base = BASE_DATE;
|
|
817
|
+
await repo.create(
|
|
818
|
+
makeInput({
|
|
819
|
+
accountConfigId: account,
|
|
820
|
+
mailboxId: mailbox,
|
|
821
|
+
messageId: "m-default",
|
|
822
|
+
subject: "not yet classified",
|
|
823
|
+
sentDate: base,
|
|
824
|
+
internalDate: base,
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
await repo.create(
|
|
828
|
+
makeInput({
|
|
829
|
+
accountConfigId: account,
|
|
830
|
+
mailboxId: mailbox,
|
|
831
|
+
messageId: "m-personal",
|
|
832
|
+
subject: "classified personal",
|
|
833
|
+
category: "personal",
|
|
834
|
+
sentDate: base - 1000,
|
|
835
|
+
internalDate: base - 1000,
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
const uncategorized = await repo.searchByMailboxWindow(
|
|
840
|
+
account,
|
|
841
|
+
mailbox,
|
|
842
|
+
{ category: ["uncategorized"] },
|
|
843
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
844
|
+
);
|
|
845
|
+
assert.deepEqual(
|
|
846
|
+
uncategorized.items.map((item) => item.subject),
|
|
847
|
+
["not yet classified"],
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
const personal = await repo.searchByMailboxWindow(
|
|
851
|
+
account,
|
|
852
|
+
mailbox,
|
|
853
|
+
{ category: ["personal"] },
|
|
854
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
855
|
+
);
|
|
856
|
+
assert.deepEqual(
|
|
857
|
+
personal.items.map((item) => item.subject),
|
|
858
|
+
["classified personal"],
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("soft-deleted rows stay out of a filtered page", async () => {
|
|
863
|
+
const account = "acct-category-deleted";
|
|
864
|
+
const mailbox = "mbx-category-deleted";
|
|
865
|
+
await repo.create(
|
|
866
|
+
makeInput({
|
|
867
|
+
accountConfigId: account,
|
|
868
|
+
mailboxId: mailbox,
|
|
869
|
+
messageId: "m-live-social",
|
|
870
|
+
subject: "live",
|
|
871
|
+
category: "social",
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
await repo.create(
|
|
875
|
+
makeInput({
|
|
876
|
+
accountConfigId: account,
|
|
877
|
+
mailboxId: mailbox,
|
|
878
|
+
messageId: "m-trashed-social",
|
|
879
|
+
subject: "trashed",
|
|
880
|
+
category: "social",
|
|
881
|
+
isDeleted: true,
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
const page = await repo.searchByMailboxWindow(
|
|
886
|
+
account,
|
|
887
|
+
mailbox,
|
|
888
|
+
{ category: ["social"] },
|
|
889
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
890
|
+
);
|
|
891
|
+
assert.deepEqual(
|
|
892
|
+
page.items.map((item) => item.subject),
|
|
893
|
+
["live"],
|
|
894
|
+
);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// The guard on I1. Without it a rare-category page silently costs a
|
|
898
|
+
// mailbox scan and no test notices: the index is invisible to
|
|
899
|
+
// vps-migrations-drift.sqlite.test.ts, which compares the committed
|
|
900
|
+
// migration set against the same generated schema the index comes from.
|
|
901
|
+
//
|
|
902
|
+
// The statements are the ones the repo itself issued, captured from the
|
|
903
|
+
// driver, so this cannot pass against a query the repo does not run. Each
|
|
904
|
+
// assertion names the category column as well as the index: SQLite picks
|
|
905
|
+
// this index on its (account_config_id, mailbox_id) prefix alone, so an
|
|
906
|
+
// index-only assertion would still pass with the predicate removed.
|
|
907
|
+
describe("query plan", () => {
|
|
908
|
+
const selectsDuring = async (
|
|
909
|
+
run: () => Promise<unknown>,
|
|
910
|
+
): Promise<string[]> => {
|
|
911
|
+
const captured: string[] = [];
|
|
912
|
+
const original = sqlite.prepare.bind(sqlite);
|
|
913
|
+
const record = (source: string) => {
|
|
914
|
+
captured.push(source);
|
|
915
|
+
return original(source);
|
|
916
|
+
};
|
|
917
|
+
sqlite.prepare = record as typeof sqlite.prepare;
|
|
918
|
+
try {
|
|
919
|
+
await run();
|
|
920
|
+
} finally {
|
|
921
|
+
sqlite.prepare = original as typeof sqlite.prepare;
|
|
922
|
+
}
|
|
923
|
+
return captured.filter((source) => /^\s*select/i.test(source));
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const plansFor = (statements: string[]): string[] => {
|
|
927
|
+
assert.ok(statements.length > 0, "the repo issued a select");
|
|
928
|
+
return statements.flatMap((source) => {
|
|
929
|
+
const parameters = new Array((source.match(/\?/g) ?? []).length).fill(
|
|
930
|
+
0,
|
|
931
|
+
);
|
|
932
|
+
const rows = sqlite
|
|
933
|
+
.prepare(`EXPLAIN QUERY PLAN ${source}`)
|
|
934
|
+
.all(...parameters) as Array<{ detail: string }>;
|
|
935
|
+
return rows.map((row) => row.detail);
|
|
936
|
+
});
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const assertServedByIndex = (plan: string[]): void => {
|
|
940
|
+
assert.ok(
|
|
941
|
+
plan.some(
|
|
942
|
+
(detail) =>
|
|
943
|
+
detail.includes("tm_by_mailbox_category_date") &&
|
|
944
|
+
detail.includes("category=?"),
|
|
945
|
+
),
|
|
946
|
+
`no plan step matched on category through the index: ${plan.join(" | ")}`,
|
|
947
|
+
);
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
test("the filtered window is served by tm_by_mailbox_category_date", async () => {
|
|
951
|
+
const statements = await selectsDuring(() =>
|
|
952
|
+
repo.searchByMailboxWindow(
|
|
953
|
+
CAT_ACCOUNT,
|
|
954
|
+
CAT_MAILBOX,
|
|
955
|
+
{ category: ["social"] },
|
|
956
|
+
{ limit: 50, order: "desc", excludeDeleted: true },
|
|
957
|
+
),
|
|
958
|
+
);
|
|
959
|
+
assertServedByIndex(plansFor(statements));
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("the filtered count is served by tm_by_mailbox_category_date", async () => {
|
|
963
|
+
const statements = await selectsDuring(() =>
|
|
964
|
+
repo.countByMailbox(
|
|
965
|
+
CAT_ACCOUNT,
|
|
966
|
+
CAT_MAILBOX,
|
|
967
|
+
{ category: ["social"] },
|
|
968
|
+
{ excludeDeleted: true },
|
|
969
|
+
),
|
|
970
|
+
);
|
|
971
|
+
assertServedByIndex(plansFor(statements));
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
});
|
|
611
975
|
});
|
|
@@ -158,6 +158,13 @@ function buildSearchConditions(search: SearchOptions): SQL[] {
|
|
|
158
158
|
if (search.attachments !== undefined) {
|
|
159
159
|
conditions.push(eq(threadMessageTable.hasAttachment, search.attachments));
|
|
160
160
|
}
|
|
161
|
+
// An equality over a column on the row, so it sits inside the keyset window
|
|
162
|
+
// rather than filtering what the window returned: a page is a full page of
|
|
163
|
+
// matches however rare the category is. Served by
|
|
164
|
+
// tm_by_mailbox_category_date.
|
|
165
|
+
if (search.category?.length) {
|
|
166
|
+
conditions.push(inArray(threadMessageTable.category, search.category));
|
|
167
|
+
}
|
|
161
168
|
|
|
162
169
|
return conditions;
|
|
163
170
|
}
|