@remit/drizzle-service 0.0.103 → 0.0.104
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 +4 -3
- package/src/repos/account.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-default.d1.test.ts +4 -0
- package/src/repos/calendar-default.sqlite.test.ts +3 -76
- package/src/repos/calendar-default.suite.ts +68 -0
- package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-put.d1.test.ts +4 -0
- package/src/repos/calendar-put.sqlite.test.ts +3 -209
- package/src/repos/calendar-put.suite.ts +210 -0
- package/src/repos/cascade-delete.ts +1 -1
- package/src/repos/filter.d1.test.ts +4 -0
- package/src/repos/filter.suite.ts +476 -0
- package/src/repos/filter.test.ts +3 -472
- package/src/repos/i4-account-config.d1.test.ts +4 -0
- package/src/repos/i4-account-config.suite.ts +131 -0
- package/src/repos/i4-account-config.test.ts +3 -127
- package/src/repos/i4-account-export-request.d1.test.ts +4 -0
- package/src/repos/i4-account-export-request.suite.ts +44 -0
- package/src/repos/i4-account-export-request.test.ts +3 -40
- package/src/repos/i4-account-setting.d1.test.ts +4 -0
- package/src/repos/i4-account-setting.suite.ts +100 -0
- package/src/repos/i4-account-setting.test.ts +3 -93
- package/src/repos/label.conformance.d1.test.ts +20 -0
- package/src/repos/message.sqlite.test.ts +54 -0
- package/src/repos/message.ts +66 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +23 -19
- package/src/repos/thread-message-field-terms.sqlite.test.ts +9 -5
- package/src/repos/thread-message.sqlite.test.ts +6 -4
- package/src/repos/thread-message.test.ts +23 -11
- package/src/repos/thread-message.ts +13 -9
- package/src/repos/thread-search-predicates.ts +36 -63
- package/src/schema/message-data.ts +3 -0
- package/src/schema-sets/mail.ts +1 -0
- package/src/search/thread-message-search.sqlite.test.ts +49 -8
- package/src/search/thread-message-search.ts +13 -4
- package/src/test-d1.ts +40 -0
- package/src/test-db-sqlite.ts +1 -1
- package/src/test-shipped-sqlite-schema.ts +8 -6
- package/src/test-stores.ts +30 -0
|
@@ -1,128 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
4
|
-
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
5
|
-
import type { AccountConfigRepo } from "./i4-account-config.js";
|
|
1
|
+
import { openSqliteTestStores } from "../test-stores.js";
|
|
2
|
+
import { accountConfigRepoSuite } from "./i4-account-config.suite.js";
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
let db: TestDb;
|
|
9
|
-
let close: () => Promise<void>;
|
|
10
|
-
let repo: AccountConfigRepo;
|
|
11
|
-
|
|
12
|
-
before(async () => {
|
|
13
|
-
({ db, close } = await createTestDb());
|
|
14
|
-
repo = createSqliteRepositories({
|
|
15
|
-
app: db,
|
|
16
|
-
mail: db,
|
|
17
|
-
search: db,
|
|
18
|
-
}).accountConfig;
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
after(async () => {
|
|
22
|
-
await close();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("create and get", async () => {
|
|
26
|
-
const userId = randomId();
|
|
27
|
-
const cfg = await repo.create({ userId, name: "My Config" });
|
|
28
|
-
|
|
29
|
-
assert.ok(cfg.accountConfigId);
|
|
30
|
-
assert.equal(cfg.userId, userId);
|
|
31
|
-
assert.equal(cfg.name, "My Config");
|
|
32
|
-
assert.equal(cfg.state, "active");
|
|
33
|
-
|
|
34
|
-
const fetched = await repo.get(cfg.accountConfigId);
|
|
35
|
-
assert.equal(fetched.accountConfigId, cfg.accountConfigId);
|
|
36
|
-
|
|
37
|
-
await repo.delete(cfg.accountConfigId);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("batchGet: WHERE id = ANY($1)", async () => {
|
|
41
|
-
const userId = randomId();
|
|
42
|
-
const c1 = await repo.create({ userId });
|
|
43
|
-
const c2 = await repo.create({ userId });
|
|
44
|
-
|
|
45
|
-
const results = await repo.get([c1.accountConfigId, c2.accountConfigId]);
|
|
46
|
-
assert.equal(results.length, 2);
|
|
47
|
-
|
|
48
|
-
await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("batchGet empty array returns []", async () => {
|
|
52
|
-
const results = await repo.get([]);
|
|
53
|
-
assert.deepEqual(results, []);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("get throws for missing config", async () => {
|
|
57
|
-
await assert.rejects(repo.get(randomId()), /not found/i);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("describe assembles collection (accountConfig + account + address)", async () => {
|
|
61
|
-
const userId = randomId();
|
|
62
|
-
const cfg = await repo.create({ userId });
|
|
63
|
-
|
|
64
|
-
const repos = createSqliteRepositories({ app: db, mail: db, search: db });
|
|
65
|
-
const accountRepo = repos.account;
|
|
66
|
-
const addrRepo = repos.address;
|
|
67
|
-
|
|
68
|
-
await accountRepo.create({
|
|
69
|
-
accountConfigId: cfg.accountConfigId,
|
|
70
|
-
username: "u",
|
|
71
|
-
email: "u@test.com",
|
|
72
|
-
isActive: true,
|
|
73
|
-
imapHost: "imap.test.com",
|
|
74
|
-
imapPort: 993,
|
|
75
|
-
imapTls: true,
|
|
76
|
-
imapStartTls: false,
|
|
77
|
-
connectionState: "not_authenticated",
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
await addrRepo.createAddress({
|
|
81
|
-
addressId: randomId(),
|
|
82
|
-
accountConfigId: cfg.accountConfigId,
|
|
83
|
-
localPart: "u",
|
|
84
|
-
domain: "test.com",
|
|
85
|
-
normalizedEmail: "u@test.com",
|
|
86
|
-
normalizedCompound: "u@test.com:u",
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const desc = await repo.describe(cfg.accountConfigId);
|
|
90
|
-
assert.equal(desc.accountConfig.length, 1);
|
|
91
|
-
assert.equal(desc.account.length, 1);
|
|
92
|
-
assert.equal(desc.address.length, 1);
|
|
93
|
-
|
|
94
|
-
await repo.delete(cfg.accountConfigId);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("listAll returns all configs", async () => {
|
|
98
|
-
const userId = randomId();
|
|
99
|
-
const c1 = await repo.create({ userId });
|
|
100
|
-
const c2 = await repo.create({ userId });
|
|
101
|
-
|
|
102
|
-
const all = await repo.listAll();
|
|
103
|
-
const ids = new Set(all.map((c) => c.accountConfigId));
|
|
104
|
-
assert.ok(ids.has(c1.accountConfigId));
|
|
105
|
-
assert.ok(ids.has(c2.accountConfigId));
|
|
106
|
-
|
|
107
|
-
await repo.deleteMany([c1.accountConfigId, c2.accountConfigId]);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe("continuation token rejection (#172)", () => {
|
|
111
|
-
for (const [label, token] of [
|
|
112
|
-
["an unparseable", "not-a-cursor"],
|
|
113
|
-
["a bare number", Buffer.from("123").toString("base64url")],
|
|
114
|
-
["a JSON array", Buffer.from("[1,2]").toString("base64url")],
|
|
115
|
-
] as const) {
|
|
116
|
-
test(`${label} token is rejected as a 400`, async () => {
|
|
117
|
-
await assert.rejects(
|
|
118
|
-
() => repo.listByUser(randomId(), { continuationToken: token }),
|
|
119
|
-
(error: unknown) => {
|
|
120
|
-
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
121
|
-
assert.equal((error as Error).name, "BadRequestError");
|
|
122
|
-
return true;
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
});
|
|
4
|
+
accountConfigRepoSuite(openSqliteTestStores);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { randomId } from "../id.js";
|
|
4
|
+
import type { OpenTestStores } from "../test-stores.js";
|
|
5
|
+
import { AccountExportRequestRepo } from "./i4-account-export-request.js";
|
|
6
|
+
|
|
7
|
+
export const accountExportRequestRepoSuite = (open: OpenTestStores): void => {
|
|
8
|
+
describe("AccountExportRequestRepo", () => {
|
|
9
|
+
let close: () => Promise<void>;
|
|
10
|
+
let repo: AccountExportRequestRepo;
|
|
11
|
+
|
|
12
|
+
before(async () => {
|
|
13
|
+
const opened = await open();
|
|
14
|
+
close = opened.close;
|
|
15
|
+
repo = new AccountExportRequestRepo(opened.stores.app.db);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
after(async () => {
|
|
19
|
+
await close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("continuation token rejection (#172)", () => {
|
|
23
|
+
for (const [label, token] of [
|
|
24
|
+
["an unparseable", "not-a-cursor"],
|
|
25
|
+
["a bare number", Buffer.from("123").toString("base64url")],
|
|
26
|
+
["a JSON array", Buffer.from("[1,2]").toString("base64url")],
|
|
27
|
+
] as const) {
|
|
28
|
+
test(`${label} token is rejected as a 400`, async () => {
|
|
29
|
+
await assert.rejects(
|
|
30
|
+
() =>
|
|
31
|
+
repo.listByAccountConfig(randomId(), {
|
|
32
|
+
continuationToken: token,
|
|
33
|
+
}),
|
|
34
|
+
(error: unknown) => {
|
|
35
|
+
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
36
|
+
assert.equal((error as Error).name, "BadRequestError");
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
};
|
|
@@ -1,41 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
|
-
import { AccountExportRequestRepo } from "./i4-account-export-request.js";
|
|
1
|
+
import { openSqliteTestStores } from "../test-stores.js";
|
|
2
|
+
import { accountExportRequestRepoSuite } from "./i4-account-export-request.suite.js";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
let db: TestDb;
|
|
8
|
-
let close: () => Promise<void>;
|
|
9
|
-
let repo: AccountExportRequestRepo;
|
|
10
|
-
|
|
11
|
-
before(async () => {
|
|
12
|
-
({ db, close } = await createTestDb());
|
|
13
|
-
repo = new AccountExportRequestRepo(db as never);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
after(async () => {
|
|
17
|
-
await close();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe("continuation token rejection (#172)", () => {
|
|
21
|
-
for (const [label, token] of [
|
|
22
|
-
["an unparseable", "not-a-cursor"],
|
|
23
|
-
["a bare number", Buffer.from("123").toString("base64url")],
|
|
24
|
-
["a JSON array", Buffer.from("[1,2]").toString("base64url")],
|
|
25
|
-
] as const) {
|
|
26
|
-
test(`${label} token is rejected as a 400`, async () => {
|
|
27
|
-
await assert.rejects(
|
|
28
|
-
() =>
|
|
29
|
-
repo.listByAccountConfig(randomId(), {
|
|
30
|
-
continuationToken: token,
|
|
31
|
-
}),
|
|
32
|
-
(error: unknown) => {
|
|
33
|
-
assert.equal((error as { statusCode?: number }).statusCode, 400);
|
|
34
|
-
assert.equal((error as Error).name, "BadRequestError");
|
|
35
|
-
return true;
|
|
36
|
-
},
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
});
|
|
4
|
+
accountExportRequestRepoSuite(openSqliteTestStores);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { randomId } from "../id.js";
|
|
4
|
+
import type { OpenTestStores } from "../test-stores.js";
|
|
5
|
+
import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
6
|
+
|
|
7
|
+
export const accountSettingRepoSuite = (open: OpenTestStores): void => {
|
|
8
|
+
describe("AccountSettingRepo", () => {
|
|
9
|
+
let close: () => Promise<void>;
|
|
10
|
+
let repo: AccountSettingRepo;
|
|
11
|
+
|
|
12
|
+
before(async () => {
|
|
13
|
+
const opened = await open();
|
|
14
|
+
close = opened.close;
|
|
15
|
+
repo = new AccountSettingRepo(opened.stores.app.db);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
after(async () => {
|
|
19
|
+
await close();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("upsert and get", async () => {
|
|
23
|
+
const accountConfigId = randomId();
|
|
24
|
+
await repo.upsert({
|
|
25
|
+
accountConfigId,
|
|
26
|
+
name: "Theme",
|
|
27
|
+
value: { kind: "String", value: "dark" },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const setting = await repo.get(accountConfigId, "Theme");
|
|
31
|
+
assert.ok(setting);
|
|
32
|
+
assert.equal(setting.name, "Theme");
|
|
33
|
+
assert.deepEqual(setting.value, { kind: "String", value: "dark" });
|
|
34
|
+
|
|
35
|
+
await repo.delete(accountConfigId, "Theme");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("upsert is idempotent (overwrites)", async () => {
|
|
39
|
+
const accountConfigId = randomId();
|
|
40
|
+
await repo.upsert({
|
|
41
|
+
accountConfigId,
|
|
42
|
+
name: "Density",
|
|
43
|
+
value: { kind: "String", value: "compact" },
|
|
44
|
+
});
|
|
45
|
+
await repo.upsert({
|
|
46
|
+
accountConfigId,
|
|
47
|
+
name: "Density",
|
|
48
|
+
value: { kind: "String", value: "comfortable" },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const setting = await repo.get(accountConfigId, "Density");
|
|
52
|
+
assert.deepEqual(setting?.value, {
|
|
53
|
+
kind: "String",
|
|
54
|
+
value: "comfortable",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await repo.delete(accountConfigId, "Density");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("get returns null when setting absent", async () => {
|
|
61
|
+
const setting = await repo.get(randomId(), "Theme");
|
|
62
|
+
assert.equal(setting, null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("listByAccountConfig returns all settings ordered by name", async () => {
|
|
66
|
+
const accountConfigId = randomId();
|
|
67
|
+
await repo.upsert({
|
|
68
|
+
accountConfigId,
|
|
69
|
+
name: "Theme",
|
|
70
|
+
value: { kind: "String", value: "dark" },
|
|
71
|
+
});
|
|
72
|
+
await repo.upsert({
|
|
73
|
+
accountConfigId,
|
|
74
|
+
name: "Density",
|
|
75
|
+
value: { kind: "String", value: "compact" },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const settings = await repo.listByAccountConfig(accountConfigId);
|
|
79
|
+
assert.equal(settings.length, 2);
|
|
80
|
+
assert.equal(settings[0].name, "Density");
|
|
81
|
+
assert.equal(settings[1].name, "Theme");
|
|
82
|
+
|
|
83
|
+
await repo.delete(accountConfigId, "Theme");
|
|
84
|
+
await repo.delete(accountConfigId, "Density");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("delete removes the setting", async () => {
|
|
88
|
+
const accountConfigId = randomId();
|
|
89
|
+
await repo.upsert({
|
|
90
|
+
accountConfigId,
|
|
91
|
+
name: "Theme",
|
|
92
|
+
value: { kind: "Boolean", value: true },
|
|
93
|
+
});
|
|
94
|
+
await repo.delete(accountConfigId, "Theme");
|
|
95
|
+
|
|
96
|
+
const setting = await repo.get(accountConfigId, "Theme");
|
|
97
|
+
assert.equal(setting, null);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
};
|
|
@@ -1,94 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
|
-
import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
1
|
+
import { openSqliteTestStores } from "../test-stores.js";
|
|
2
|
+
import { accountSettingRepoSuite } from "./i4-account-setting.suite.js";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
let db: TestDb;
|
|
8
|
-
let close: () => Promise<void>;
|
|
9
|
-
let repo: AccountSettingRepo;
|
|
10
|
-
|
|
11
|
-
before(async () => {
|
|
12
|
-
({ db, close } = await createTestDb());
|
|
13
|
-
repo = new AccountSettingRepo(db as never);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
after(async () => {
|
|
17
|
-
await close();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("upsert and get", async () => {
|
|
21
|
-
const accountConfigId = randomId();
|
|
22
|
-
await repo.upsert({
|
|
23
|
-
accountConfigId,
|
|
24
|
-
name: "Theme",
|
|
25
|
-
value: { kind: "String", value: "dark" },
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const setting = await repo.get(accountConfigId, "Theme");
|
|
29
|
-
assert.ok(setting);
|
|
30
|
-
assert.equal(setting.name, "Theme");
|
|
31
|
-
assert.deepEqual(setting.value, { kind: "String", value: "dark" });
|
|
32
|
-
|
|
33
|
-
await repo.delete(accountConfigId, "Theme");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("upsert is idempotent (overwrites)", async () => {
|
|
37
|
-
const accountConfigId = randomId();
|
|
38
|
-
await repo.upsert({
|
|
39
|
-
accountConfigId,
|
|
40
|
-
name: "Density",
|
|
41
|
-
value: { kind: "String", value: "compact" },
|
|
42
|
-
});
|
|
43
|
-
await repo.upsert({
|
|
44
|
-
accountConfigId,
|
|
45
|
-
name: "Density",
|
|
46
|
-
value: { kind: "String", value: "comfortable" },
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const setting = await repo.get(accountConfigId, "Density");
|
|
50
|
-
assert.deepEqual(setting?.value, { kind: "String", value: "comfortable" });
|
|
51
|
-
|
|
52
|
-
await repo.delete(accountConfigId, "Density");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("get returns null when setting absent", async () => {
|
|
56
|
-
const setting = await repo.get(randomId(), "Theme");
|
|
57
|
-
assert.equal(setting, null);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("listByAccountConfig returns all settings ordered by name", async () => {
|
|
61
|
-
const accountConfigId = randomId();
|
|
62
|
-
await repo.upsert({
|
|
63
|
-
accountConfigId,
|
|
64
|
-
name: "Theme",
|
|
65
|
-
value: { kind: "String", value: "dark" },
|
|
66
|
-
});
|
|
67
|
-
await repo.upsert({
|
|
68
|
-
accountConfigId,
|
|
69
|
-
name: "Density",
|
|
70
|
-
value: { kind: "String", value: "compact" },
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const settings = await repo.listByAccountConfig(accountConfigId);
|
|
74
|
-
assert.equal(settings.length, 2);
|
|
75
|
-
assert.equal(settings[0].name, "Density");
|
|
76
|
-
assert.equal(settings[1].name, "Theme");
|
|
77
|
-
|
|
78
|
-
await repo.delete(accountConfigId, "Theme");
|
|
79
|
-
await repo.delete(accountConfigId, "Density");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("delete removes the setting", async () => {
|
|
83
|
-
const accountConfigId = randomId();
|
|
84
|
-
await repo.upsert({
|
|
85
|
-
accountConfigId,
|
|
86
|
-
name: "Theme",
|
|
87
|
-
value: { kind: "Boolean", value: true },
|
|
88
|
-
});
|
|
89
|
-
await repo.delete(accountConfigId, "Theme");
|
|
90
|
-
|
|
91
|
-
const setting = await repo.get(accountConfigId, "Theme");
|
|
92
|
-
assert.equal(setting, null);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
4
|
+
accountSettingRepoSuite(openSqliteTestStores);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { labelRepositoryConformance } from "@remit/data-ports/conformance";
|
|
2
|
+
import { NotFoundError } from "../error.js";
|
|
3
|
+
import { randomId } from "../id.js";
|
|
4
|
+
import { openD1TestStores } from "../test-d1.js";
|
|
5
|
+
import { LabelRepo } from "./label.js";
|
|
6
|
+
|
|
7
|
+
let close: (() => Promise<void>) | undefined;
|
|
8
|
+
|
|
9
|
+
labelRepositoryConformance({
|
|
10
|
+
async createRepository() {
|
|
11
|
+
const opened = await openD1TestStores();
|
|
12
|
+
close = opened.close;
|
|
13
|
+
return new LabelRepo(opened.stores.app.db);
|
|
14
|
+
},
|
|
15
|
+
teardown: async () => {
|
|
16
|
+
await close?.();
|
|
17
|
+
},
|
|
18
|
+
makeId: () => randomId(),
|
|
19
|
+
isNotFoundError: (error) => error instanceof NotFoundError,
|
|
20
|
+
});
|
|
@@ -279,4 +279,58 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
279
279
|
await repo.delete(MESSAGE_ID);
|
|
280
280
|
await assert.rejects(() => repo.get(MESSAGE_ID));
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
test("listMissingSearchText names stored bodies without search text, after the cursor", async () => {
|
|
284
|
+
const id = (n: number): string => `00000000-0000-0000-3333-00000000000${n}`;
|
|
285
|
+
for (const n of [1, 2, 3, 4]) {
|
|
286
|
+
await repo.create({ ...BASE_INPUT, messageId: id(n), uid: 100 + n });
|
|
287
|
+
}
|
|
288
|
+
for (const n of [1, 2, 4]) {
|
|
289
|
+
await repo.update(id(n), { bodyStorageKey: `file:///bodies/${n}` });
|
|
290
|
+
}
|
|
291
|
+
await repo.setSearchText(id(2), "already indexed");
|
|
292
|
+
|
|
293
|
+
const ours = (rows: Array<{ messageId: string }>): string[] =>
|
|
294
|
+
rows
|
|
295
|
+
.map((row) => row.messageId)
|
|
296
|
+
.filter((messageId) => messageId.includes("-3333-"));
|
|
297
|
+
|
|
298
|
+
assert.deepEqual(ours(await repo.listMissingSearchText("", 100)), [
|
|
299
|
+
id(1),
|
|
300
|
+
id(4),
|
|
301
|
+
]);
|
|
302
|
+
assert.deepEqual(ours(await repo.listMissingSearchText(id(1), 100)), [
|
|
303
|
+
id(4),
|
|
304
|
+
]);
|
|
305
|
+
assert.deepEqual(
|
|
306
|
+
await repo
|
|
307
|
+
.listMissingSearchText(id(1), 100)
|
|
308
|
+
.then((rows) => rows.find((row) => row.messageId === id(4))),
|
|
309
|
+
{
|
|
310
|
+
messageId: id(4),
|
|
311
|
+
bodyStorageKey: "file:///bodies/4",
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("copySearchText gives a copy the source's search text", async () => {
|
|
317
|
+
const source = "00000000-0000-0000-4444-000000000001";
|
|
318
|
+
const copy = "00000000-0000-0000-4444-000000000002";
|
|
319
|
+
for (const [messageId, uid] of [
|
|
320
|
+
[source, 201],
|
|
321
|
+
[copy, 202],
|
|
322
|
+
] as const) {
|
|
323
|
+
await repo.create({ ...BASE_INPUT, messageId, uid });
|
|
324
|
+
await repo.update(messageId, { bodyStorageKey: "file:///bodies/shared" });
|
|
325
|
+
}
|
|
326
|
+
await repo.setSearchText(source, "the shared body");
|
|
327
|
+
|
|
328
|
+
await repo.copySearchText(source, copy);
|
|
329
|
+
|
|
330
|
+
const missing = await repo.listMissingSearchText("", 100);
|
|
331
|
+
assert.equal(
|
|
332
|
+
missing.some((row) => row.messageId === copy),
|
|
333
|
+
false,
|
|
334
|
+
);
|
|
335
|
+
});
|
|
282
336
|
});
|
package/src/repos/message.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
envelopeTable,
|
|
40
40
|
messageFlagTable,
|
|
41
41
|
messageReferenceTable,
|
|
42
|
+
messageSearchTextTable,
|
|
42
43
|
messageTable,
|
|
43
44
|
rawMessageStorageTable,
|
|
44
45
|
} from "../schema/message-data.js";
|
|
@@ -153,6 +154,9 @@ export function deleteMessageSubtree(
|
|
|
153
154
|
.where(
|
|
154
155
|
and(inList(bodyStructureRefetchTable.messageId, messageIds), guard),
|
|
155
156
|
),
|
|
157
|
+
db
|
|
158
|
+
.delete(messageSearchTextTable)
|
|
159
|
+
.where(and(inList(messageSearchTextTable.messageId, messageIds), guard)),
|
|
156
160
|
db
|
|
157
161
|
.delete(envelopeTable)
|
|
158
162
|
.where(and(inList(envelopeTable.messageId, messageIds), guard)),
|
|
@@ -444,6 +448,68 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
444
448
|
return this.get(messageId);
|
|
445
449
|
}
|
|
446
450
|
|
|
451
|
+
async setSearchText(messageId: string, text: string): Promise<void> {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
await this.db
|
|
454
|
+
.insert(messageSearchTextTable)
|
|
455
|
+
.values({ messageId, text, createdAt: now, updatedAt: now })
|
|
456
|
+
.onConflictDoUpdate({
|
|
457
|
+
target: messageSearchTextTable.messageId,
|
|
458
|
+
set: { text, updatedAt: now },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async copySearchText(
|
|
463
|
+
sourceMessageId: string,
|
|
464
|
+
targetMessageId: string,
|
|
465
|
+
): Promise<void> {
|
|
466
|
+
const [source] = await this.db
|
|
467
|
+
.select({ text: messageSearchTextTable.text })
|
|
468
|
+
.from(messageSearchTextTable)
|
|
469
|
+
.where(eq(messageSearchTextTable.messageId, sourceMessageId));
|
|
470
|
+
if (!source) return;
|
|
471
|
+
await this.setSearchText(targetMessageId, source.text);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async syncSearch(messageIds: string[]): Promise<void> {
|
|
475
|
+
if (messageIds.length === 0) return;
|
|
476
|
+
const listings = await this.db
|
|
477
|
+
.select({ threadMessageId: threadMessageTable.threadMessageId })
|
|
478
|
+
.from(threadMessageTable)
|
|
479
|
+
.where(inList(threadMessageTable.messageId, messageIds));
|
|
480
|
+
await this.search.sync(listings.map((row) => row.threadMessageId));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async listMissingSearchText(
|
|
484
|
+
after: string,
|
|
485
|
+
limit: number,
|
|
486
|
+
): ReturnType<IMessageRepository["listMissingSearchText"]> {
|
|
487
|
+
const rows = await this.db
|
|
488
|
+
.select({
|
|
489
|
+
messageId: messageTable.messageId,
|
|
490
|
+
bodyStorageKey: messageTable.bodyStorageKey,
|
|
491
|
+
})
|
|
492
|
+
.from(messageTable)
|
|
493
|
+
.leftJoin(
|
|
494
|
+
messageSearchTextTable,
|
|
495
|
+
eq(messageSearchTextTable.messageId, messageTable.messageId),
|
|
496
|
+
)
|
|
497
|
+
.where(
|
|
498
|
+
and(
|
|
499
|
+
gt(messageTable.messageId, after),
|
|
500
|
+
isNotNull(messageTable.bodyStorageKey),
|
|
501
|
+
isNull(messageSearchTextTable.messageId),
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
.orderBy(asc(messageTable.messageId))
|
|
505
|
+
.limit(limit);
|
|
506
|
+
return rows.flatMap((row) =>
|
|
507
|
+
row.bodyStorageKey
|
|
508
|
+
? [{ messageId: row.messageId, bodyStorageKey: row.bodyStorageKey }]
|
|
509
|
+
: [],
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
447
513
|
async delete(messageId: string): Promise<void> {
|
|
448
514
|
await this.deleteMany([messageId]);
|
|
449
515
|
}
|
|
@@ -11,16 +11,12 @@ import assert from "node:assert/strict";
|
|
|
11
11
|
import { after, before, describe, test } from "node:test";
|
|
12
12
|
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
13
13
|
import { addressTable } from "../schema/i4-address.js";
|
|
14
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
14
15
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
15
16
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
16
17
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
17
|
-
import { shippedIndexDdl } from "../test-shipped-sqlite-schema.js";
|
|
18
18
|
import type { DrizzleThreadMessageRepository } from "./thread-message.js";
|
|
19
19
|
|
|
20
|
-
// The index the mail baseline creates for this predicate, read from the one
|
|
21
|
-
// committed source so the plan assertion below is about the real object.
|
|
22
|
-
const mutedIndexDdl = shippedIndexDdl("address_by_normalized_email");
|
|
23
|
-
|
|
24
20
|
const ACCOUNT = "acct-seam";
|
|
25
21
|
const MAILBOX = "mbx-seam";
|
|
26
22
|
const SCOPE = new Set([MAILBOX]);
|
|
@@ -57,10 +53,13 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
|
|
|
57
53
|
|
|
58
54
|
before(async () => {
|
|
59
55
|
({ db, sqlite, close } = await createSqliteTestDb(
|
|
60
|
-
{
|
|
56
|
+
{
|
|
57
|
+
threadMessage: threadMessageTable,
|
|
58
|
+
address: addressTable,
|
|
59
|
+
messageSearchText: messageSearchTextTable,
|
|
60
|
+
},
|
|
61
61
|
{ searchIndex: true },
|
|
62
62
|
));
|
|
63
|
-
sqlite.exec(mutedIndexDdl);
|
|
64
63
|
repo = createSqliteRepositories({
|
|
65
64
|
app: db,
|
|
66
65
|
mail: db,
|
|
@@ -86,15 +85,20 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
|
|
|
86
85
|
}),
|
|
87
86
|
);
|
|
88
87
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
88
|
+
const parcel = makeInput({
|
|
89
|
+
subject: "unrelated note",
|
|
90
|
+
fromEmail: "noise@example.com",
|
|
91
|
+
fromName: "Noise",
|
|
92
|
+
snippet: "Your parcel was left with the concierge",
|
|
93
|
+
sentDate: BASE_DATE,
|
|
94
|
+
});
|
|
95
|
+
await db.insert(messageSearchTextTable).values({
|
|
96
|
+
messageId: parcel.messageId,
|
|
97
|
+
text: "Your parcel was left with the concierge",
|
|
98
|
+
createdAt: 0,
|
|
99
|
+
updatedAt: 0,
|
|
100
|
+
});
|
|
101
|
+
await repo.create(parcel);
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
test("the term reaches the body text, not just subject and From", async () => {
|
|
@@ -123,16 +127,16 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
|
|
|
123
127
|
assert.equal(result.items.length, 1);
|
|
124
128
|
});
|
|
125
129
|
|
|
126
|
-
test("a
|
|
130
|
+
test("a short word prefix reads the body too", async () => {
|
|
127
131
|
const result = await repo.searchByDate(
|
|
128
132
|
ACCOUNT,
|
|
129
|
-
{ query: "
|
|
133
|
+
{ query: "co" },
|
|
130
134
|
{ mailboxIds: SCOPE, limit: 50 },
|
|
131
135
|
);
|
|
132
136
|
|
|
133
137
|
assert.ok(
|
|
134
138
|
result.items.some((item) => item.snippet?.includes("concierge")),
|
|
135
|
-
"the
|
|
139
|
+
"the prefix matched the body preview",
|
|
136
140
|
);
|
|
137
141
|
});
|
|
138
142
|
|