@remit/drizzle-service 0.0.76 → 0.0.78
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/outbox-created-cleanup.sqlite.test.ts +83 -0
- package/src/repair/thread-message-category-contract.ts +1 -1
- package/src/repair/thread-message-category.ts +4 -5
- package/src/repos/message.sqlite.test.ts +7 -5
- package/src/repos/message.test.ts +3 -5
- package/src/repos/message.ts +15 -16
- package/src/repos/serialized-writes.sqlite.test.ts +1 -1
- package/src/repos/unit-of-work.test.ts +8 -4
- package/src/schema/message-data.ts +2 -2
- package/src/schema/outbox.ts +16 -1
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import {
|
|
5
|
+
applyMigration,
|
|
6
|
+
migrationJournal,
|
|
7
|
+
} from "./test-shipped-sqlite-schema.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The data migration that clears the `message.created` backlog (reader#1063),
|
|
11
|
+
* against a database that already holds those rows.
|
|
12
|
+
*
|
|
13
|
+
* Every other outbox test starts from an empty table, so the case that matters
|
|
14
|
+
* — an instance upgrading with 30,906 undrained rows the drain filters out and
|
|
15
|
+
* the column's type no longer admits — is only reachable by running the shipped
|
|
16
|
+
* migrations over a table populated at the point the upgrade finds it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const CLEANUP_TAG = "0022_drop_message_created_outbox";
|
|
20
|
+
|
|
21
|
+
const seed = (sqlite: Database.Database, event: string, id: string): void => {
|
|
22
|
+
sqlite
|
|
23
|
+
.prepare(
|
|
24
|
+
`INSERT INTO outbox (id, message_id, event, payload, created_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
26
|
+
)
|
|
27
|
+
.run(id, `message-${id}`, event, JSON.stringify({ messageId: id }), 1);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const unprocessed = (sqlite: Database.Database): string[] =>
|
|
31
|
+
(
|
|
32
|
+
sqlite
|
|
33
|
+
.prepare(
|
|
34
|
+
"SELECT event FROM outbox WHERE processed_at IS NULL ORDER BY event",
|
|
35
|
+
)
|
|
36
|
+
.all() as Array<{ event: string }>
|
|
37
|
+
).map((row) => row.event);
|
|
38
|
+
|
|
39
|
+
const upgradedDatabase = (): Database.Database => {
|
|
40
|
+
const entries = [...migrationJournal()].sort(
|
|
41
|
+
(left, right) => left.idx - right.idx,
|
|
42
|
+
);
|
|
43
|
+
const cleanup = entries.findIndex((entry) => entry.tag === CLEANUP_TAG);
|
|
44
|
+
assert.notEqual(cleanup, -1, `${CLEANUP_TAG} is not in the journal`);
|
|
45
|
+
|
|
46
|
+
const sqlite = new Database(":memory:");
|
|
47
|
+
for (const entry of entries.slice(0, cleanup)) {
|
|
48
|
+
applyMigration(sqlite, entry.tag);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
seed(sqlite, "message.created", "created-1");
|
|
52
|
+
seed(sqlite, "message.created", "created-2");
|
|
53
|
+
seed(sqlite, "message.body_synced", "synced-1");
|
|
54
|
+
seed(sqlite, "message.moved", "moved-1");
|
|
55
|
+
|
|
56
|
+
for (const entry of entries.slice(cleanup)) {
|
|
57
|
+
applyMigration(sqlite, entry.tag);
|
|
58
|
+
}
|
|
59
|
+
return sqlite;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("the message.created outbox cleanup", () => {
|
|
63
|
+
test("clears the rows an upgrading instance already has", () => {
|
|
64
|
+
const sqlite = upgradedDatabase();
|
|
65
|
+
|
|
66
|
+
const left = sqlite
|
|
67
|
+
.prepare("SELECT COUNT(*) AS n FROM outbox WHERE event = ?")
|
|
68
|
+
.get("message.created") as { n: number };
|
|
69
|
+
|
|
70
|
+
assert.equal(left.n, 0);
|
|
71
|
+
sqlite.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("leaves the drained kinds alone", () => {
|
|
75
|
+
const sqlite = upgradedDatabase();
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(unprocessed(sqlite), [
|
|
78
|
+
"message.body_synced",
|
|
79
|
+
"message.moved",
|
|
80
|
+
]);
|
|
81
|
+
sqlite.close();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -53,10 +53,9 @@
|
|
|
53
53
|
* `message.category` is still `uncategorized`, so the pending exclusion
|
|
54
54
|
* above refuses the row outright.
|
|
55
55
|
*
|
|
56
|
-
* Only the fourth is interesting, and it is not impossible. It is
|
|
57
|
-
* through
|
|
58
|
-
*
|
|
59
|
-
* skip guard is `if (message.bodyStorageKey && !force)` (`body-sync.ts`), and
|
|
56
|
+
* Only the fourth is interesting, and it is not impossible. It is reachable
|
|
57
|
+
* through the one path that classifies, which is re-enterable: the skip guard
|
|
58
|
+
* is `if (hasStoredBody(message.bodyStorageKey) && !force)` (`body-sync.ts`), and
|
|
60
59
|
* `force` is a live event flag — the read-miss re-arm cue, resolved in the
|
|
61
60
|
* imap-worker's `sync-message-body.ts`. A forced re-fetch re-runs
|
|
62
61
|
* `classifyByHeaders` over the same bytes, so it normally rewrites the value it
|
|
@@ -351,7 +350,7 @@ export const formatCheckReport = (
|
|
|
351
350
|
` ahead: ${report.ahead} ${plural(report.ahead)} classified against a pending message — after #326 body-sync writes the row before the message, so this is a classification in flight. Not repaired: pushing it back to pending would undo a correct classification and serve Unclassified for mail that is already classified. Expected non-zero on a live instance mid-sync, zero on a quiescent one.`,
|
|
352
351
|
`fan-out: ${report.fanOutMessages} messages holding ${report.fanOutRows} thread_message rows — the multi-row shape #326 hardens against. Expected zero: deriveMessageId and deriveThreadMessageId are both mailbox-independent, so a message in two mailboxes collapses to one row, and the reachable case is thread-root drift.`,
|
|
353
352
|
`orphans: ${report.orphanRows} ${plural(report.orphanRows)} with no message row — not repaired, nothing to copy. Expected zero.`,
|
|
354
|
-
`
|
|
353
|
+
`uncategorized: ${report.notYetClassified} ${plural(report.notYetClassified)} uncategorized against an uncategorized message — the row agrees with its message, so there is nothing to repair and this figure is untouched by the repair. It is NOT the same as "not classified yet": it mixes mail the classifier has not reached with mail it reached and had nothing to say about. Only \`message.classification_state\` separates them (\`SELECT classification_state, count(*) FROM message WHERE category = 'uncategorized' GROUP BY 1\`), and a large \`NotExamined\` count is the cohort worth acting on. Expected non-zero on a live instance.`,
|
|
355
354
|
`divergent per mailbox: ${
|
|
356
355
|
report.divergentByMailbox.length === 0
|
|
357
356
|
? "none"
|
|
@@ -52,7 +52,7 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
52
52
|
await close();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test("create returns a MessageItem and writes
|
|
55
|
+
test("create returns a MessageItem and writes no outbox row", async () => {
|
|
56
56
|
const item = await repo.create(BASE_INPUT);
|
|
57
57
|
assert.equal(item.messageId, MESSAGE_ID);
|
|
58
58
|
assert.equal(item.status, "active");
|
|
@@ -62,9 +62,11 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
62
62
|
.select()
|
|
63
63
|
.from(outboxTable)
|
|
64
64
|
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
65
|
-
assert.equal(
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
assert.equal(
|
|
66
|
+
rows.length,
|
|
67
|
+
0,
|
|
68
|
+
"a message with no body yet has nothing to index",
|
|
69
|
+
);
|
|
68
70
|
});
|
|
69
71
|
|
|
70
72
|
test("boolean and json columns round-trip", async () => {
|
|
@@ -120,7 +122,7 @@ describe("DrizzleMessageRepository (sqlite)", () => {
|
|
|
120
122
|
assert.equal(afterUpdate.placementVerdict?.action, "MoveToInbox");
|
|
121
123
|
});
|
|
122
124
|
|
|
123
|
-
test("duplicate messageId throws CreateFailedConflictError and
|
|
125
|
+
test("duplicate messageId throws CreateFailedConflictError and appends no outbox row", async () => {
|
|
124
126
|
const before = await db
|
|
125
127
|
.select()
|
|
126
128
|
.from(outboxTable)
|
|
@@ -44,7 +44,7 @@ describe("DrizzleMessageRepository", () => {
|
|
|
44
44
|
await stop();
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
describe("create — writes message
|
|
47
|
+
describe("create — writes the message row only", () => {
|
|
48
48
|
test("creates a message and returns MessageItem", async () => {
|
|
49
49
|
const item = await messageRepo.create(BASE_MESSAGE_INPUT);
|
|
50
50
|
assert.equal(item.messageId, MESSAGE_ID);
|
|
@@ -56,16 +56,14 @@ describe("DrizzleMessageRepository", () => {
|
|
|
56
56
|
assert.ok(typeof item.updatedAt === "number");
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
test("writes
|
|
59
|
+
test("writes no outbox row — there is nothing to index before the body", async () => {
|
|
60
60
|
const { outboxTable } = await import("../schema/message-data.js");
|
|
61
61
|
const { eq } = await import("drizzle-orm");
|
|
62
62
|
const rows = await db
|
|
63
63
|
.select()
|
|
64
64
|
.from(outboxTable)
|
|
65
65
|
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
66
|
-
assert.
|
|
67
|
-
assert.equal(rows[0].event, "message.created");
|
|
68
|
-
assert.deepStrictEqual(rows[0].payload, { messageId: MESSAGE_ID });
|
|
66
|
+
assert.equal(rows.length, 0);
|
|
69
67
|
});
|
|
70
68
|
|
|
71
69
|
test("duplicate messageId throws CreateFailedConflictError and writes no extra outbox row", async () => {
|
package/src/repos/message.ts
CHANGED
|
@@ -59,6 +59,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
59
59
|
status: row.status,
|
|
60
60
|
syncStatus: row.syncStatus,
|
|
61
61
|
category: row.category,
|
|
62
|
+
classificationState: row.classificationState,
|
|
62
63
|
authenticityVerdict: row.authenticityVerdict,
|
|
63
64
|
hasListUnsubscribe: row.hasListUnsubscribe,
|
|
64
65
|
movedByRemit: row.movedByRemit,
|
|
@@ -106,7 +107,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
106
107
|
* the search-index worker relays a search-index REMOVE and the vectors are
|
|
107
108
|
* dropped.
|
|
108
109
|
*/
|
|
109
|
-
export const MESSAGE_REMOVED_EVENT = "message.removed";
|
|
110
|
+
export const MESSAGE_REMOVED_EVENT = "message.removed" as const;
|
|
110
111
|
|
|
111
112
|
export type SubtreeDb = Pick<Db<Record<string, unknown>>, "delete" | "insert">;
|
|
112
113
|
|
|
@@ -185,6 +186,8 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
185
186
|
status: input.status ?? ("active" as const),
|
|
186
187
|
syncStatus: input.syncStatus ?? ("pending" as const),
|
|
187
188
|
category: input.category ?? ("uncategorized" as const),
|
|
189
|
+
classificationState:
|
|
190
|
+
input.classificationState ?? ("NotExamined" as const),
|
|
188
191
|
authenticityVerdict:
|
|
189
192
|
input.authenticityVerdict ?? ("NotEvaluated" as const),
|
|
190
193
|
hasListUnsubscribe: input.hasListUnsubscribe ?? false,
|
|
@@ -204,22 +207,15 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
204
207
|
updatedAt: now,
|
|
205
208
|
};
|
|
206
209
|
|
|
207
|
-
// Faithful to ElectroDB message.create: a duplicate messageId
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
210
|
+
// Faithful to ElectroDB message.create: a duplicate messageId raises a
|
|
211
|
+
// unique-constraint violation, surfaced as the domain conflict error.
|
|
212
|
+
//
|
|
213
|
+
// No outbox event. A freshly created message has neither a body nor a
|
|
214
|
+
// threadMessage yet, so there is nothing to index — the search-index
|
|
215
|
+
// relay never drained a creation event, and the rows accumulated forever
|
|
216
|
+
// (reader#1063). Body-sync appends the event once there is content.
|
|
211
217
|
try {
|
|
212
|
-
await
|
|
213
|
-
await tx.insert(messageTable).values(row);
|
|
214
|
-
|
|
215
|
-
await tx.insert(outboxTable).values({
|
|
216
|
-
id: randomUUID(),
|
|
217
|
-
messageId: input.messageId,
|
|
218
|
-
event: "message.created",
|
|
219
|
-
payload: { messageId: input.messageId },
|
|
220
|
-
createdAt: new Date(),
|
|
221
|
-
});
|
|
222
|
-
});
|
|
218
|
+
await this.db.insert(messageTable).values(row);
|
|
223
219
|
} catch (error) {
|
|
224
220
|
if (isUniqueViolation(error)) {
|
|
225
221
|
throw new CreateFailedConflictError("Message", input);
|
|
@@ -368,6 +364,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
368
364
|
? { syncStatus: input.syncStatus }
|
|
369
365
|
: {}),
|
|
370
366
|
...(input.category !== undefined ? { category: input.category } : {}),
|
|
367
|
+
...(input.classificationState !== undefined
|
|
368
|
+
? { classificationState: input.classificationState }
|
|
369
|
+
: {}),
|
|
371
370
|
...(input.authenticityVerdict !== undefined
|
|
372
371
|
? { authenticityVerdict: input.authenticityVerdict }
|
|
373
372
|
: {}),
|
|
@@ -21,7 +21,7 @@ import { runInTransaction, serializeSqliteWrites } from "../tx.js";
|
|
|
21
21
|
const row = (id: string) => ({
|
|
22
22
|
id,
|
|
23
23
|
messageId: `msg-${id}`,
|
|
24
|
-
event: "message.body_synced",
|
|
24
|
+
event: "message.body_synced" as const,
|
|
25
25
|
payload: { messageId: `msg-${id}` },
|
|
26
26
|
createdAt: new Date(),
|
|
27
27
|
});
|
|
@@ -22,7 +22,7 @@ describe("DrizzleUnitOfWork", () => {
|
|
|
22
22
|
const MAILBOX_ID = "00000000-0000-0000-2222-000000000002";
|
|
23
23
|
const NOW = 1700000000000;
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const writeEnvelopeMessageAndBody = async (
|
|
26
26
|
repos: Parameters<Parameters<DrizzleUnitOfWork["transaction"]>[0]>[0],
|
|
27
27
|
) => {
|
|
28
28
|
await repos.envelope.upsertEnvelope({
|
|
@@ -43,6 +43,10 @@ describe("DrizzleUnitOfWork", () => {
|
|
|
43
43
|
envelopeId: deriveEnvelopeId(MESSAGE_ID),
|
|
44
44
|
rootBodyPartId: deriveRootBodyPartId(MESSAGE_ID),
|
|
45
45
|
});
|
|
46
|
+
// Body-sync appends the transactional-outbox row; the create emits none.
|
|
47
|
+
await repos.message.update(MESSAGE_ID, {
|
|
48
|
+
bodyStorageKey: "body/hello.json",
|
|
49
|
+
});
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
const rows = async () => {
|
|
@@ -74,7 +78,7 @@ describe("DrizzleUnitOfWork", () => {
|
|
|
74
78
|
await assert.rejects(
|
|
75
79
|
() =>
|
|
76
80
|
unitOfWork.transaction(async (repos) => {
|
|
77
|
-
await
|
|
81
|
+
await writeEnvelopeMessageAndBody(repos);
|
|
78
82
|
// A later write in the set fails after the envelope, message and its
|
|
79
83
|
// transactional-outbox row have already been written.
|
|
80
84
|
throw new Error("thread write failed");
|
|
@@ -93,13 +97,13 @@ describe("DrizzleUnitOfWork", () => {
|
|
|
93
97
|
});
|
|
94
98
|
|
|
95
99
|
test("a successful transaction commits the data rows and the outbox row", async () => {
|
|
96
|
-
await unitOfWork.transaction(
|
|
100
|
+
await unitOfWork.transaction(writeEnvelopeMessageAndBody);
|
|
97
101
|
|
|
98
102
|
const { envelopes, messages, outbox } = await rows();
|
|
99
103
|
assert.equal(envelopes.length, 1);
|
|
100
104
|
assert.equal(messages.length, 1);
|
|
101
105
|
assert.equal(outbox.length, 1);
|
|
102
|
-
assert.equal(outbox[0].event, "message.
|
|
106
|
+
assert.equal(outbox[0].event, "message.body_synced");
|
|
103
107
|
assert.deepStrictEqual(outbox[0].payload, { messageId: MESSAGE_ID });
|
|
104
108
|
});
|
|
105
109
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as entities from "@remit/drizzle-sqlite-schema";
|
|
2
|
-
import { outboxTable } from "./outbox.js";
|
|
2
|
+
import { OUTBOX_EVENTS, type OutboxEvent, outboxTable } from "./outbox.js";
|
|
3
3
|
|
|
4
|
-
export { outboxTable };
|
|
4
|
+
export { OUTBOX_EVENTS, type OutboxEvent, outboxTable };
|
|
5
5
|
|
|
6
6
|
const bodyPartContentTable = entities.bodyPartContents;
|
|
7
7
|
const bodyPartParameterTable = entities.bodyPartParameters;
|
package/src/schema/outbox.ts
CHANGED
|
@@ -10,12 +10,27 @@ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
|
10
10
|
* Exported raw for committed-migration generation (schema-full-sqlite) as well
|
|
11
11
|
* as for the repos and the drain logic.
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* The event vocabulary a producer may append. Every kind here is drained by the
|
|
15
|
+
* search-index relay (`DRAIN_EVENTS`); a kind nothing drains accumulates
|
|
16
|
+
* undrained rows forever, which is what `message.created` did (reader#1063).
|
|
17
|
+
* The column carries the union so a new producer cannot invent a kind without
|
|
18
|
+
* adding it here, where the drain-coverage test picks it up.
|
|
19
|
+
*/
|
|
20
|
+
export const OUTBOX_EVENTS = [
|
|
21
|
+
"message.body_synced",
|
|
22
|
+
"message.moved",
|
|
23
|
+
"message.removed",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export type OutboxEvent = (typeof OUTBOX_EVENTS)[number];
|
|
27
|
+
|
|
13
28
|
export const outboxTable = sqliteTable(
|
|
14
29
|
"outbox",
|
|
15
30
|
{
|
|
16
31
|
id: text("id").primaryKey(),
|
|
17
32
|
messageId: text("message_id").notNull(),
|
|
18
|
-
event: text("event").notNull(),
|
|
33
|
+
event: text("event", { enum: OUTBOX_EVENTS }).notNull(),
|
|
19
34
|
payload: text("payload", { mode: "json" }).notNull(),
|
|
20
35
|
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
|
21
36
|
.$defaultFn(() => new Date())
|