@remit/drizzle-service 0.0.98 → 0.0.100
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/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +45 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +1 -4
- package/src/generated-enum.test.ts +8 -0
- package/src/generated-id.test.ts +19 -0
- package/src/id.test.ts +0 -8
- package/src/id.ts +0 -6
- package/src/index.ts +16 -6
- package/src/repair/address-display-name.sqlite.test.ts +3 -5
- package/src/repair/address-display-name.ts +8 -3
- package/src/repair/junk-only-address.sqlite.test.ts +8 -36
- package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
- package/src/repos/account.conformance.sqlite.test.ts +1 -1
- package/src/repos/calendar-default.sqlite.test.ts +6 -6
- package/src/repos/calendar-put.sqlite.test.ts +40 -39
- package/src/repos/calendar-store.ts +273 -0
- package/src/repos/cascade-delete.sqlite.test.ts +7 -9
- package/src/repos/cascade-delete.test.ts +6 -13
- package/src/repos/cascade-delete.ts +127 -97
- package/src/repos/envelope.test.ts +1 -17
- package/src/repos/envelope.ts +83 -67
- package/src/repos/filter.test.ts +22 -2
- package/src/repos/filter.ts +1 -5
- package/src/repos/i4-account-config.test.ts +2 -2
- package/src/repos/i4-account-config.ts +5 -2
- package/src/repos/i4-account.test.ts +6 -2
- package/src/repos/i4-account.ts +12 -56
- package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
- package/src/repos/i4-address.ts +58 -90
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
- package/src/repos/i4-mailbox-special-use.ts +9 -6
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
- package/src/repos/i4-mailbox.test.ts +17 -62
- package/src/repos/i4-mailbox.ts +121 -94
- package/src/repos/i4-organize-job-request.ts +0 -4
- package/src/repos/i4-outbox-attachment.ts +62 -33
- package/src/repos/mappers.ts +0 -16
- package/src/repos/message.sqlite.test.ts +62 -102
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +227 -218
- package/src/repos/sync-warning.ts +76 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
- package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
- package/src/repos/thread-message.sqlite.test.ts +2 -1
- package/src/repos/thread-message.test.ts +17 -4
- package/src/repos/thread-message.ts +502 -285
- package/src/repos/thread-search-predicates.ts +24 -26
- package/src/schema/message-data.ts +4 -8
- package/src/schema/sync-warning.ts +3 -0
- package/src/schema-sets/app.ts +17 -0
- package/src/schema-sets/mail.ts +32 -0
- package/src/schema-sets/search.ts +5 -0
- package/src/schema-version-accounting.sqlite.test.ts +56 -43
- package/src/schema.ts +3 -4
- package/src/search/thread-message-search.sqlite.test.ts +131 -0
- package/src/search/thread-message-search.ts +271 -0
- package/src/sqlite-client.ts +5 -7
- package/src/sqlite-repositories.ts +75 -0
- package/src/test-db-sqlite.ts +5 -13
- package/src/test-shipped-sqlite-schema.ts +46 -45
- package/src/test-sqlite.ts +35 -11
- package/src/vps-migrations-drift.sqlite.test.ts +81 -15
- package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
- package/src/account-synced-services-migration.sqlite.test.ts +0 -56
- package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
- package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
- package/src/repair/duplicate-mailbox-mail.ts +0 -98
- package/src/repair/thread-message-category-contract.ts +0 -505
- package/src/repair/thread-message-category.sqlite.test.ts +0 -100
- package/src/repair/thread-message-category.ts +0 -415
- package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
- package/src/repos/calendar-unit-of-work.ts +0 -31
- package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
- package/src/repos/filter-anchor-transaction.test.ts +0 -136
- package/src/repos/filter-anchor-transaction.ts +0 -45
- package/src/repos/filter-anchor.test.ts +0 -131
- package/src/repos/filter-anchor.ts +0 -105
- package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
- package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
- package/src/repos/search-index-shape.test.ts +0 -82
- package/src/repos/search-index-shape.ts +0 -56
- package/src/repos/serialized-writes.sqlite.test.ts +0 -199
- package/src/repos/unit-of-work.sqlite.test.ts +0 -138
- package/src/repos/unit-of-work.test.ts +0 -109
- package/src/repos/unit-of-work.ts +0 -31
- package/src/schema/outbox.ts +0 -46
- package/src/schema-full-sqlite.ts +0 -13
- package/src/tx.sqlite.test.ts +0 -83
- package/src/tx.ts +0 -233
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import { FilterScope } from "@remit/domain-enums";
|
|
4
|
-
import { NotFoundError } from "../error.js";
|
|
5
|
-
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
6
|
-
import { FilterRepo } from "./filter.js";
|
|
7
|
-
import { FilterAnchorRepo } from "./filter-anchor.js";
|
|
8
|
-
import { DrizzleFilterAnchorTransaction } from "./filter-anchor-transaction.js";
|
|
9
|
-
|
|
10
|
-
describe("DrizzleFilterAnchorTransaction", () => {
|
|
11
|
-
let db: TestDb;
|
|
12
|
-
let close: () => Promise<void>;
|
|
13
|
-
let transaction: DrizzleFilterAnchorTransaction;
|
|
14
|
-
let filterRepo: FilterRepo;
|
|
15
|
-
let filterAnchorRepo: FilterAnchorRepo;
|
|
16
|
-
|
|
17
|
-
before(async () => {
|
|
18
|
-
({ db, close } = await createTestDb());
|
|
19
|
-
transaction = new DrizzleFilterAnchorTransaction(db as never);
|
|
20
|
-
filterRepo = new FilterRepo(db as never);
|
|
21
|
-
filterAnchorRepo = new FilterAnchorRepo(db as never);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
after(async () => {
|
|
25
|
-
await close();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("creates the Filter and its FilterAnchor together", async () => {
|
|
29
|
-
const accountConfigId = randomId();
|
|
30
|
-
|
|
31
|
-
const filter = await transaction.createWithAnchor(
|
|
32
|
-
{
|
|
33
|
-
accountConfigId,
|
|
34
|
-
name: "Booking confirmations",
|
|
35
|
-
scope: FilterScope.Standing,
|
|
36
|
-
hasAnchor: true,
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
accountConfigId,
|
|
40
|
-
anchorMessageId: randomId(),
|
|
41
|
-
anchorEmbedding: [0.1, 0.2, 0.3],
|
|
42
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
43
|
-
anchorSourceText: "Your booking is confirmed",
|
|
44
|
-
},
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
assert.equal(filter.hasAnchor, true);
|
|
48
|
-
const anchor = await filterAnchorRepo.get(accountConfigId, filter.filterId);
|
|
49
|
-
assert.ok(anchor, "the FilterAnchor row must exist");
|
|
50
|
-
assert.equal(anchor?.anchorSourceText, "Your booking is confirmed");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("creates a purely-literal Filter when anchor is null", async () => {
|
|
54
|
-
const accountConfigId = randomId();
|
|
55
|
-
|
|
56
|
-
const filter = await transaction.createWithAnchor(
|
|
57
|
-
{
|
|
58
|
-
accountConfigId,
|
|
59
|
-
name: "From billing",
|
|
60
|
-
scope: FilterScope.Standing,
|
|
61
|
-
hasAnchor: false,
|
|
62
|
-
},
|
|
63
|
-
null,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
assert.equal(filter.hasAnchor, false);
|
|
67
|
-
const anchor = await filterAnchorRepo.get(accountConfigId, filter.filterId);
|
|
68
|
-
assert.equal(anchor, null);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("rolls back the Filter row when the FilterAnchor write fails (#351)", async () => {
|
|
72
|
-
const accountConfigId = randomId();
|
|
73
|
-
|
|
74
|
-
await assert.rejects(() =>
|
|
75
|
-
transaction.createWithAnchor(
|
|
76
|
-
{
|
|
77
|
-
accountConfigId,
|
|
78
|
-
name: "Broken anchor",
|
|
79
|
-
scope: FilterScope.Standing,
|
|
80
|
-
hasAnchor: true,
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
accountConfigId,
|
|
84
|
-
anchorMessageId: randomId(),
|
|
85
|
-
anchorEmbedding: [0.1, 0.2, 0.3],
|
|
86
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
87
|
-
// A NOT NULL column at the DB level — simulates a real write
|
|
88
|
-
// failure on the second half of the pair (network blip,
|
|
89
|
-
// transient error), the exact case #351 leaves broken today.
|
|
90
|
-
anchorSourceText: null as unknown as string,
|
|
91
|
-
},
|
|
92
|
-
),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const filters = await filterRepo.listByAccountConfig(accountConfigId);
|
|
96
|
-
assert.equal(
|
|
97
|
-
filters.length,
|
|
98
|
-
0,
|
|
99
|
-
"the Filter row must not survive when its anchor write fails",
|
|
100
|
-
);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("the caller never sees a Filter row with hasAnchor: true and no anchor (#351)", async () => {
|
|
104
|
-
const accountConfigId = randomId();
|
|
105
|
-
|
|
106
|
-
await assert.rejects(() =>
|
|
107
|
-
transaction.createWithAnchor(
|
|
108
|
-
{
|
|
109
|
-
accountConfigId,
|
|
110
|
-
name: "Broken anchor 2",
|
|
111
|
-
scope: FilterScope.Standing,
|
|
112
|
-
hasAnchor: true,
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
accountConfigId,
|
|
116
|
-
anchorMessageId: randomId(),
|
|
117
|
-
anchorEmbedding: [0.1, 0.2, 0.3],
|
|
118
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
119
|
-
anchorSourceText: null as unknown as string,
|
|
120
|
-
},
|
|
121
|
-
),
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
const filters = await filterRepo.listByAccountConfig(accountConfigId);
|
|
125
|
-
const orphan = filters.find((f) => f.hasAnchor);
|
|
126
|
-
assert.equal(
|
|
127
|
-
orphan,
|
|
128
|
-
undefined,
|
|
129
|
-
"no Filter with hasAnchor: true may exist without a FilterAnchor row",
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("get on a never-created filter still throws NotFoundError (sanity)", async () => {
|
|
134
|
-
await assert.rejects(filterRepo.get(randomId(), randomId()), NotFoundError);
|
|
135
|
-
});
|
|
136
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
CreateFilterAnchorInput,
|
|
3
|
-
CreateFilterInput,
|
|
4
|
-
FilterItem,
|
|
5
|
-
IFilterAnchorTransaction,
|
|
6
|
-
} from "@remit/data-ports";
|
|
7
|
-
import type { Db } from "../db.js";
|
|
8
|
-
import { runInTransaction } from "../tx.js";
|
|
9
|
-
import { FilterRepo } from "./filter.js";
|
|
10
|
-
import { FilterAnchorRepo } from "./filter-anchor.js";
|
|
11
|
-
|
|
12
|
-
type DB = Db<Record<string, unknown>>;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Runs the `Filter` create and its optional `FilterAnchor` write in one
|
|
16
|
-
* transaction (#351): a failure on either side rolls back both, so a
|
|
17
|
-
* `Filter` row can never persist with `hasAnchor: true` and no matching
|
|
18
|
-
* `FilterAnchor` row, and a `FilterAnchor` write failure surfaces as a
|
|
19
|
-
* failed create request rather than a silently-broken filter.
|
|
20
|
-
*/
|
|
21
|
-
export class DrizzleFilterAnchorTransaction
|
|
22
|
-
implements IFilterAnchorTransaction
|
|
23
|
-
{
|
|
24
|
-
constructor(private db: DB) {}
|
|
25
|
-
|
|
26
|
-
createWithAnchor(
|
|
27
|
-
filterInput: CreateFilterInput,
|
|
28
|
-
anchorInput: Omit<CreateFilterAnchorInput, "filterId"> | null,
|
|
29
|
-
): Promise<FilterItem> {
|
|
30
|
-
return runInTransaction(this.db, async (tx) => {
|
|
31
|
-
const filterRepo = new FilterRepo(tx as never);
|
|
32
|
-
const filter = await filterRepo.create(filterInput);
|
|
33
|
-
|
|
34
|
-
if (anchorInput) {
|
|
35
|
-
const filterAnchorRepo = new FilterAnchorRepo(tx as never);
|
|
36
|
-
await filterAnchorRepo.put({
|
|
37
|
-
...anchorInput,
|
|
38
|
-
filterId: filter.filterId,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return filter;
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { after, before, describe, test } from "node:test";
|
|
3
|
-
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
|
-
import { FilterAnchorRepo } from "./filter-anchor.js";
|
|
5
|
-
|
|
6
|
-
describe("FilterAnchorRepo", () => {
|
|
7
|
-
let db: TestDb;
|
|
8
|
-
let close: () => Promise<void>;
|
|
9
|
-
let repo: FilterAnchorRepo;
|
|
10
|
-
|
|
11
|
-
before(async () => {
|
|
12
|
-
({ db, close } = await createTestDb());
|
|
13
|
-
repo = new FilterAnchorRepo(db as never);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
after(async () => {
|
|
17
|
-
await close();
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("put creates the row and round-trips the anchor vector", async () => {
|
|
21
|
-
const accountConfigId = randomId();
|
|
22
|
-
const filterId = randomId();
|
|
23
|
-
const anchorMessageId = randomId();
|
|
24
|
-
const anchorEmbedding = [0.1, -0.2, 0.3, 0];
|
|
25
|
-
|
|
26
|
-
const anchor = await repo.put({
|
|
27
|
-
accountConfigId,
|
|
28
|
-
filterId,
|
|
29
|
-
anchorEmbedding,
|
|
30
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
31
|
-
anchorSourceText: "Your booking is confirmed for...",
|
|
32
|
-
anchorMessageId,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
assert.deepEqual(anchor.anchorEmbedding, anchorEmbedding);
|
|
36
|
-
assert.equal(anchor.anchorMessageId, anchorMessageId);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("get returns null when no anchor exists for the filter", async () => {
|
|
40
|
-
const anchor = await repo.get(randomId(), randomId());
|
|
41
|
-
assert.equal(anchor, null);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("put upserts — a re-embed migration overwrites the existing row", async () => {
|
|
45
|
-
const accountConfigId = randomId();
|
|
46
|
-
const filterId = randomId();
|
|
47
|
-
const anchorMessageId = randomId();
|
|
48
|
-
|
|
49
|
-
await repo.put({
|
|
50
|
-
accountConfigId,
|
|
51
|
-
filterId,
|
|
52
|
-
anchorEmbedding: [1, 2, 3],
|
|
53
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
54
|
-
anchorSourceText: "original",
|
|
55
|
-
anchorMessageId,
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const migrated = await repo.put({
|
|
59
|
-
accountConfigId,
|
|
60
|
-
filterId,
|
|
61
|
-
anchorEmbedding: [4, 5, 6],
|
|
62
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v3:0@1536",
|
|
63
|
-
anchorSourceText: "original",
|
|
64
|
-
anchorMessageId,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
assert.deepEqual(migrated.anchorEmbedding, [4, 5, 6]);
|
|
68
|
-
assert.equal(
|
|
69
|
-
migrated.anchorEmbeddingId,
|
|
70
|
-
"amazon.titan-embed-text-v3:0@1536",
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const reread = await repo.get(accountConfigId, filterId);
|
|
74
|
-
assert.deepEqual(reread?.anchorEmbedding, [4, 5, 6]);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("listByAccountConfig returns every anchor for the account config", async () => {
|
|
78
|
-
const accountConfigId = randomId();
|
|
79
|
-
const filterIdA = randomId();
|
|
80
|
-
const filterIdB = randomId();
|
|
81
|
-
const otherConfigId = randomId();
|
|
82
|
-
const otherFilterId = randomId();
|
|
83
|
-
|
|
84
|
-
await repo.put({
|
|
85
|
-
accountConfigId,
|
|
86
|
-
filterId: filterIdA,
|
|
87
|
-
anchorEmbedding: [1, 2, 3],
|
|
88
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
89
|
-
anchorSourceText: "a",
|
|
90
|
-
anchorMessageId: randomId(),
|
|
91
|
-
});
|
|
92
|
-
await repo.put({
|
|
93
|
-
accountConfigId,
|
|
94
|
-
filterId: filterIdB,
|
|
95
|
-
anchorEmbedding: [4, 5, 6],
|
|
96
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
97
|
-
anchorSourceText: "b",
|
|
98
|
-
anchorMessageId: randomId(),
|
|
99
|
-
});
|
|
100
|
-
await repo.put({
|
|
101
|
-
accountConfigId: otherConfigId,
|
|
102
|
-
filterId: otherFilterId,
|
|
103
|
-
anchorEmbedding: [7, 8, 9],
|
|
104
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
105
|
-
anchorSourceText: "other",
|
|
106
|
-
anchorMessageId: randomId(),
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const anchors = await repo.listByAccountConfig(accountConfigId);
|
|
110
|
-
const filterIds = anchors.map((a) => a.filterId).sort();
|
|
111
|
-
assert.deepEqual(filterIds, [filterIdA, filterIdB].sort());
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("delete removes the row", async () => {
|
|
115
|
-
const accountConfigId = randomId();
|
|
116
|
-
const filterId = randomId();
|
|
117
|
-
|
|
118
|
-
await repo.put({
|
|
119
|
-
accountConfigId,
|
|
120
|
-
filterId,
|
|
121
|
-
anchorEmbedding: [1],
|
|
122
|
-
anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
|
|
123
|
-
anchorSourceText: "text",
|
|
124
|
-
anchorMessageId: randomId(),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
await repo.delete(accountConfigId, filterId);
|
|
128
|
-
|
|
129
|
-
assert.equal(await repo.get(accountConfigId, filterId), null);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
CreateFilterAnchorInput,
|
|
3
|
-
FilterAnchorItem,
|
|
4
|
-
IFilterAnchorRepository,
|
|
5
|
-
} from "@remit/data-ports";
|
|
6
|
-
import { and, eq } from "drizzle-orm";
|
|
7
|
-
import type { Db } from "../db.js";
|
|
8
|
-
import { filterAnchorTable } from "../schema.js";
|
|
9
|
-
|
|
10
|
-
type DB = Db<Record<string, unknown>>;
|
|
11
|
-
|
|
12
|
-
function rowToFilterAnchor(
|
|
13
|
-
row: typeof filterAnchorTable.$inferSelect,
|
|
14
|
-
): FilterAnchorItem {
|
|
15
|
-
return {
|
|
16
|
-
accountConfigId: row.accountConfigId,
|
|
17
|
-
filterId: row.filterId,
|
|
18
|
-
anchorEmbedding: row.anchorEmbedding as number[],
|
|
19
|
-
anchorEmbeddingId: row.anchorEmbeddingId,
|
|
20
|
-
anchorSourceText: row.anchorSourceText,
|
|
21
|
-
anchorMessageId: row.anchorMessageId,
|
|
22
|
-
createdAt: row.createdAt,
|
|
23
|
-
updatedAt: row.updatedAt,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Relational counterpart to `FilterAnchorService` (remit-electrodb-service). The
|
|
29
|
-
* anchor vector is persisted inline on the row (RFC 034 Decision 2.1/2.3); the
|
|
30
|
-
* vector store's per-message chunk vectors are read only by the search
|
|
31
|
-
* service when it builds the anchor, never re-derived here at match time.
|
|
32
|
-
*/
|
|
33
|
-
export class FilterAnchorRepo implements IFilterAnchorRepository {
|
|
34
|
-
constructor(private db: DB) {}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Upsert — a filter's anchor is (re)computed once at save time (RFC 034
|
|
38
|
-
* Decision 2.1) and again only by an explicit re-embed migration
|
|
39
|
-
* (Decision 2.4).
|
|
40
|
-
*/
|
|
41
|
-
async put(input: CreateFilterAnchorInput): Promise<FilterAnchorItem> {
|
|
42
|
-
const now = Date.now();
|
|
43
|
-
const [row] = await this.db
|
|
44
|
-
.insert(filterAnchorTable)
|
|
45
|
-
.values({
|
|
46
|
-
accountConfigId: input.accountConfigId,
|
|
47
|
-
filterId: input.filterId,
|
|
48
|
-
anchorEmbedding: input.anchorEmbedding,
|
|
49
|
-
anchorEmbeddingId: input.anchorEmbeddingId,
|
|
50
|
-
anchorSourceText: input.anchorSourceText,
|
|
51
|
-
anchorMessageId: input.anchorMessageId,
|
|
52
|
-
createdAt: now,
|
|
53
|
-
updatedAt: now,
|
|
54
|
-
})
|
|
55
|
-
.onConflictDoUpdate({
|
|
56
|
-
target: [filterAnchorTable.accountConfigId, filterAnchorTable.filterId],
|
|
57
|
-
set: {
|
|
58
|
-
anchorEmbedding: input.anchorEmbedding,
|
|
59
|
-
anchorEmbeddingId: input.anchorEmbeddingId,
|
|
60
|
-
anchorSourceText: input.anchorSourceText,
|
|
61
|
-
anchorMessageId: input.anchorMessageId,
|
|
62
|
-
updatedAt: now,
|
|
63
|
-
},
|
|
64
|
-
})
|
|
65
|
-
.returning();
|
|
66
|
-
return rowToFilterAnchor(row);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async get(
|
|
70
|
-
accountConfigId: string,
|
|
71
|
-
filterId: string,
|
|
72
|
-
): Promise<FilterAnchorItem | null> {
|
|
73
|
-
const [row] = await this.db
|
|
74
|
-
.select()
|
|
75
|
-
.from(filterAnchorTable)
|
|
76
|
-
.where(
|
|
77
|
-
and(
|
|
78
|
-
eq(filterAnchorTable.accountConfigId, accountConfigId),
|
|
79
|
-
eq(filterAnchorTable.filterId, filterId),
|
|
80
|
-
),
|
|
81
|
-
);
|
|
82
|
-
return row ? rowToFilterAnchor(row) : null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async listByAccountConfig(
|
|
86
|
-
accountConfigId: string,
|
|
87
|
-
): Promise<FilterAnchorItem[]> {
|
|
88
|
-
const rows = await this.db
|
|
89
|
-
.select()
|
|
90
|
-
.from(filterAnchorTable)
|
|
91
|
-
.where(eq(filterAnchorTable.accountConfigId, accountConfigId));
|
|
92
|
-
return rows.map(rowToFilterAnchor);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async delete(accountConfigId: string, filterId: string): Promise<void> {
|
|
96
|
-
await this.db
|
|
97
|
-
.delete(filterAnchorTable)
|
|
98
|
-
.where(
|
|
99
|
-
and(
|
|
100
|
-
eq(filterAnchorTable.accountConfigId, accountConfigId),
|
|
101
|
-
eq(filterAnchorTable.filterId, filterId),
|
|
102
|
-
),
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { describe, it } from "node:test";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The path a stale appointment last pointed at is a display-only sibling row
|
|
7
|
-
* (`FolderRoleAppointmentLabel`, #887). "Never read by resolution" is meant to
|
|
8
|
-
* be structural rather than a habit: this repository composes one setting name
|
|
9
|
-
* and has no way to reach the other. Read the source to prove it, because the
|
|
10
|
-
* guarantee is about what the file cannot do, not about what any one call
|
|
11
|
-
* returns.
|
|
12
|
-
*/
|
|
13
|
-
describe("MailboxSpecialUseRepo and the appointment label row", () => {
|
|
14
|
-
const source = readFileSync(
|
|
15
|
-
new URL("./i4-mailbox-special-use.ts", import.meta.url),
|
|
16
|
-
"utf8",
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
it("never names the label setting", () => {
|
|
20
|
-
assert.equal(source.includes("FolderRoleAppointmentLabel"), false);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("composes the appointment name in exactly one place", () => {
|
|
24
|
-
const uses = source.split("composeFolderRoleAppointmentName(").length - 1;
|
|
25
|
-
assert.equal(uses, 1);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The two data migrations this release carries, against a database written by
|
|
3
|
-
* the build before it.
|
|
4
|
-
*
|
|
5
|
-
* Both exist because a predicate changed meaning under rows already on disk.
|
|
6
|
-
* `carriesForeignUid` reads `originalUid` without consulting `status`, so a
|
|
7
|
-
* settled row that a pre-#1217 build left one on answers "this uid belongs to
|
|
8
|
-
* another folder" forever — and Empty Trash refuses to remove it after the
|
|
9
|
-
* server copy is gone (reader#1230). And the give-up signal moved from
|
|
10
|
-
* `active` + `failed` to its own value, so an abandoned delete written before
|
|
11
|
-
* the upgrade would go quiet: the row stays where it was handed back and the
|
|
12
|
-
* user is told nothing.
|
|
13
|
-
*
|
|
14
|
-
* Applied through the shipped journal rather than a schema pushed from the
|
|
15
|
-
* table objects, because a data migration that never runs is exactly the
|
|
16
|
-
* failure being pinned.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import assert from "node:assert/strict";
|
|
20
|
-
import { after, before, describe, test } from "node:test";
|
|
21
|
-
import Database from "better-sqlite3";
|
|
22
|
-
import {
|
|
23
|
-
applyMigration,
|
|
24
|
-
migrationJournal,
|
|
25
|
-
} from "../test-shipped-sqlite-schema.js";
|
|
26
|
-
|
|
27
|
-
const STALE_ORIGINAL_UID = "msg-stale-original-uid";
|
|
28
|
-
const IN_FLIGHT = "msg-in-flight";
|
|
29
|
-
const GAVE_UP = "msg-gave-up";
|
|
30
|
-
const RETRYING_DELETE = "msg-retrying-delete";
|
|
31
|
-
|
|
32
|
-
/** The migration that clears a settled row's stale `original_uid`. */
|
|
33
|
-
const ORIGINAL_UID_BACKFILL = 24;
|
|
34
|
-
|
|
35
|
-
/** The migration that carries an abandoned delete onto the give-up value. */
|
|
36
|
-
const GIVE_UP_BACKFILL = 25;
|
|
37
|
-
|
|
38
|
-
interface MessageRow {
|
|
39
|
-
message_id: string;
|
|
40
|
-
status: string;
|
|
41
|
-
sync_status: string;
|
|
42
|
-
abandoned_mutation: string;
|
|
43
|
-
original_uid: number | null;
|
|
44
|
-
original_mailbox_id: string | null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe("the placement backfills carry a pre-upgrade database forward", () => {
|
|
48
|
-
let sqlite: Database.Database;
|
|
49
|
-
|
|
50
|
-
const rowFor = (messageId: string): MessageRow =>
|
|
51
|
-
sqlite
|
|
52
|
-
.prepare("SELECT * FROM message WHERE message_id = ?")
|
|
53
|
-
.get(messageId) as MessageRow;
|
|
54
|
-
|
|
55
|
-
before(() => {
|
|
56
|
-
sqlite = new Database(":memory:");
|
|
57
|
-
const entries = migrationJournal();
|
|
58
|
-
|
|
59
|
-
// Everything the previous release shipped, and nothing this one adds.
|
|
60
|
-
for (const entry of entries.filter(
|
|
61
|
-
(candidate) => candidate.idx < ORIGINAL_UID_BACKFILL,
|
|
62
|
-
)) {
|
|
63
|
-
applyMigration(sqlite, entry.tag);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const insert = sqlite.prepare(
|
|
67
|
-
`INSERT INTO message (
|
|
68
|
-
message_id, mailbox_id, uid, sequence_number, rfc822_size,
|
|
69
|
-
internal_date, envelope_id, root_body_part_id,
|
|
70
|
-
status, sync_status, original_mailbox_id, original_uid,
|
|
71
|
-
created_at, updated_at
|
|
72
|
-
) VALUES (?, ?, ?, 1, 100, 0, ?, ?, ?, ?, ?, ?, 0, 0)`,
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
// Settled, with the `original_uid` a pre-#1217 `updateUid` left behind —
|
|
76
|
-
// and Trash handing back the source's own number, which on a young
|
|
77
|
-
// account is the common case rather than the exotic one.
|
|
78
|
-
insert.run(
|
|
79
|
-
STALE_ORIGINAL_UID,
|
|
80
|
-
"mbx-trash",
|
|
81
|
-
10,
|
|
82
|
-
`env-${STALE_ORIGINAL_UID}`,
|
|
83
|
-
`bp-${STALE_ORIGINAL_UID}`,
|
|
84
|
-
"active",
|
|
85
|
-
"synced",
|
|
86
|
-
"mbx-inbox",
|
|
87
|
-
10,
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
// Genuinely mid-move: this row's `original_uid` is not stale and must
|
|
91
|
-
// survive, or the guard that stops a move binding somebody else's uid
|
|
92
|
-
// stops working on every row the upgrade touched.
|
|
93
|
-
insert.run(
|
|
94
|
-
IN_FLIGHT,
|
|
95
|
-
"mbx-archive",
|
|
96
|
-
42,
|
|
97
|
-
`env-${IN_FLIGHT}`,
|
|
98
|
-
`bp-${IN_FLIGHT}`,
|
|
99
|
-
"moving",
|
|
100
|
-
"pending",
|
|
101
|
-
"mbx-inbox",
|
|
102
|
-
42,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
// The pair a give-up wrote before this release. Two writers produced it —
|
|
106
|
-
// `abandonDelete` and the paused-cursor hand-back for an unproven MOVE —
|
|
107
|
-
// through the identical `restoreSourcePlacement` call, so the row cannot
|
|
108
|
-
// say which.
|
|
109
|
-
insert.run(
|
|
110
|
-
GAVE_UP,
|
|
111
|
-
"mbx-inbox",
|
|
112
|
-
7,
|
|
113
|
-
`env-${GAVE_UP}`,
|
|
114
|
-
`bp-${GAVE_UP}`,
|
|
115
|
-
"active",
|
|
116
|
-
"failed",
|
|
117
|
-
"mbx-inbox",
|
|
118
|
-
null,
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
// A transient attempt with a redelivery behind it. It writes the same
|
|
122
|
-
// `failed`, and keeping `deleting` beside it is the only thing that tells
|
|
123
|
-
// the two apart — so the backfill must not touch it.
|
|
124
|
-
insert.run(
|
|
125
|
-
RETRYING_DELETE,
|
|
126
|
-
"mbx-trash",
|
|
127
|
-
8,
|
|
128
|
-
`env-${RETRYING_DELETE}`,
|
|
129
|
-
`bp-${RETRYING_DELETE}`,
|
|
130
|
-
"deleting",
|
|
131
|
-
"failed",
|
|
132
|
-
"mbx-inbox",
|
|
133
|
-
8,
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
for (const entry of entries.filter(
|
|
137
|
-
(candidate) => candidate.idx >= ORIGINAL_UID_BACKFILL,
|
|
138
|
-
)) {
|
|
139
|
-
applyMigration(sqlite, entry.tag);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
after(() => {
|
|
144
|
-
sqlite.close();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("the journal ships both backfills", () => {
|
|
148
|
-
const idxs = migrationJournal().map((entry) => entry.idx);
|
|
149
|
-
assert.ok(idxs.includes(ORIGINAL_UID_BACKFILL));
|
|
150
|
-
assert.ok(idxs.includes(GIVE_UP_BACKFILL));
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test("a settled row loses the stale uid that made it look foreign", () => {
|
|
154
|
-
const row = rowFor(STALE_ORIGINAL_UID);
|
|
155
|
-
assert.equal(row.original_uid, null);
|
|
156
|
-
assert.equal(
|
|
157
|
-
row.original_mailbox_id,
|
|
158
|
-
"mbx-inbox",
|
|
159
|
-
"Undo still restores to the folder the message came from",
|
|
160
|
-
);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("a row still mid-move keeps the uid its guard depends on", () => {
|
|
164
|
-
assert.equal(rowFor(IN_FLIGHT).original_uid, 42);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("a give-up keeps its chip, on the value the client now reads", () => {
|
|
168
|
-
const row = rowFor(GAVE_UP);
|
|
169
|
-
assert.equal(row.sync_status, "abandoned");
|
|
170
|
-
assert.equal(row.status, "active");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* The two pre-upgrade writers are indistinguishable in the row, so the
|
|
175
|
-
* backfill has to pick one label for both. It picks the survivable mistake:
|
|
176
|
-
* a wrongly-labelled move offers a folder picker the user can dismiss, while
|
|
177
|
-
* a wrongly-labelled delete offers a button that destroys the message — the
|
|
178
|
-
* #1229 defect, on a press the user believed was a repair.
|
|
179
|
-
*/
|
|
180
|
-
test("and is never labelled a delete, which would offer to destroy it", () => {
|
|
181
|
-
assert.equal(rowFor(GAVE_UP).abandoned_mutation, "move");
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test("a delete mid-retry is left exactly as it was", () => {
|
|
185
|
-
const row = rowFor(RETRYING_DELETE);
|
|
186
|
-
assert.equal(row.sync_status, "failed");
|
|
187
|
-
assert.equal(row.abandoned_mutation, "none");
|
|
188
|
-
assert.equal(row.status, "deleting");
|
|
189
|
-
});
|
|
190
|
-
});
|