@remit/drizzle-service 0.0.1
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/drizzle.config.ts +15 -0
- package/package.json +52 -0
- package/src/db.ts +13 -0
- package/src/dialect.ts +13 -0
- package/src/error.ts +37 -0
- package/src/id.ts +50 -0
- package/src/index.ts +62 -0
- package/src/pagination.ts +29 -0
- package/src/repos/cascade-delete.sqlite.test.ts +159 -0
- package/src/repos/cascade-delete.test.ts +423 -0
- package/src/repos/cascade-delete.ts +219 -0
- package/src/repos/envelope.sqlite.test.ts +94 -0
- package/src/repos/envelope.test.ts +225 -0
- package/src/repos/envelope.ts +342 -0
- package/src/repos/filter-anchor.test.ts +131 -0
- package/src/repos/filter-anchor.ts +105 -0
- package/src/repos/filter.test.ts +279 -0
- package/src/repos/filter.ts +253 -0
- package/src/repos/i4-account-config.test.ts +105 -0
- package/src/repos/i4-account-config.ts +257 -0
- package/src/repos/i4-account-export-request.ts +141 -0
- package/src/repos/i4-account-setting.test.ts +94 -0
- package/src/repos/i4-account-setting.ts +92 -0
- package/src/repos/i4-account.test.ts +223 -0
- package/src/repos/i4-account.ts +380 -0
- package/src/repos/i4-address-wellknown.ts +31 -0
- package/src/repos/i4-address.test.ts +358 -0
- package/src/repos/i4-address.ts +613 -0
- package/src/repos/i4-mailbox-lock.test.ts +368 -0
- package/src/repos/i4-mailbox-lock.ts +140 -0
- package/src/repos/i4-mailbox-special-use.ts +165 -0
- package/src/repos/i4-mailbox.test.ts +188 -0
- package/src/repos/i4-mailbox.ts +347 -0
- package/src/repos/i4-message-flag-push.test.ts +189 -0
- package/src/repos/i4-message-flag-push.ts +173 -0
- package/src/repos/i4-message-placement-move.test.ts +135 -0
- package/src/repos/i4-message-placement-move.ts +144 -0
- package/src/repos/i4-organize-job-request.ts +142 -0
- package/src/repos/i4-outbox-message.test.ts +298 -0
- package/src/repos/i4-outbox-message.ts +299 -0
- package/src/repos/label.conformance.sqlite.test.ts +23 -0
- package/src/repos/label.conformance.test.ts +19 -0
- package/src/repos/label.ts +125 -0
- package/src/repos/mappers.ts +171 -0
- package/src/repos/message-flag.ts +162 -0
- package/src/repos/message-label.test.ts +110 -0
- package/src/repos/message-label.ts +96 -0
- package/src/repos/message.sqlite.test.ts +118 -0
- package/src/repos/message.test.ts +488 -0
- package/src/repos/message.ts +558 -0
- package/src/repos/serialized-writes.sqlite.test.ts +199 -0
- package/src/repos/test-helpers.ts +222 -0
- package/src/repos/thread-message.sqlite.test.ts +198 -0
- package/src/repos/thread-message.test.ts +744 -0
- package/src/repos/thread-message.ts +832 -0
- package/src/repos/thread-search-predicates.ts +79 -0
- package/src/repos/unit-of-work.sqlite.test.ts +138 -0
- package/src/repos/unit-of-work.test.ts +105 -0
- package/src/repos/unit-of-work.ts +31 -0
- package/src/schema/active-entities.ts +19 -0
- package/src/schema/i4-account-config.ts +4 -0
- package/src/schema/i4-account-export-request.ts +3 -0
- package/src/schema/i4-account-setting.ts +3 -0
- package/src/schema/i4-address.ts +3 -0
- package/src/schema/i4-mailbox-lock.ts +3 -0
- package/src/schema/i4-mailbox.ts +4 -0
- package/src/schema/i4-message-flag-push.ts +3 -0
- package/src/schema/i4-message-placement-move.ts +3 -0
- package/src/schema/i4-organize-job-request.ts +1 -0
- package/src/schema/i4-outbox-message.ts +3 -0
- package/src/schema/message-data.ts +44 -0
- package/src/schema/outbox.ts +74 -0
- package/src/schema/thread-message.ts +3 -0
- package/src/schema-full-sqlite.ts +11 -0
- package/src/schema-full.ts +16 -0
- package/src/schema.ts +28 -0
- package/src/sqlite-client.ts +52 -0
- package/src/test-db-sqlite.ts +75 -0
- package/src/test-db.ts +76 -0
- package/src/tx.ts +208 -0
- package/src/vps-migrations-drift.test.ts +34 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
envelopeId as deriveEnvelopeId,
|
|
5
|
+
rootBodyPartId as deriveRootBodyPartId,
|
|
6
|
+
} from "../id.js";
|
|
7
|
+
import { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
8
|
+
import { DrizzleMessageRepository } from "./message.js";
|
|
9
|
+
import { createTestDb, type TestDb } from "./test-helpers.js";
|
|
10
|
+
|
|
11
|
+
describe("DrizzleMessageRepository", () => {
|
|
12
|
+
let db: TestDb;
|
|
13
|
+
let stop: () => Promise<void>;
|
|
14
|
+
let messageRepo: DrizzleMessageRepository;
|
|
15
|
+
let envelopeRepo: DrizzleEnvelopeRepository;
|
|
16
|
+
|
|
17
|
+
const MESSAGE_ID = "00000000-0000-0000-1111-000000000001";
|
|
18
|
+
const MAILBOX_ID = "00000000-0000-0000-1111-000000000002";
|
|
19
|
+
const ENVELOPE_ID = deriveEnvelopeId(MESSAGE_ID);
|
|
20
|
+
const ROOT_BODY_PART_ID = deriveRootBodyPartId(MESSAGE_ID);
|
|
21
|
+
const NOW = 1700000000000;
|
|
22
|
+
|
|
23
|
+
const BASE_MESSAGE_INPUT = {
|
|
24
|
+
messageId: MESSAGE_ID,
|
|
25
|
+
mailboxId: MAILBOX_ID,
|
|
26
|
+
uid: 42,
|
|
27
|
+
sequenceNumber: 1,
|
|
28
|
+
rfc822Size: 1024,
|
|
29
|
+
internalDate: NOW,
|
|
30
|
+
messageIdHeader: "<test@example.com>",
|
|
31
|
+
envelopeId: ENVELOPE_ID,
|
|
32
|
+
rootBodyPartId: ROOT_BODY_PART_ID,
|
|
33
|
+
status: "active" as const,
|
|
34
|
+
syncStatus: "synced" as const,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
before(async () => {
|
|
38
|
+
({ db, stop } = await createTestDb());
|
|
39
|
+
messageRepo = new DrizzleMessageRepository(db);
|
|
40
|
+
envelopeRepo = new DrizzleEnvelopeRepository(db);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
after(async () => {
|
|
44
|
+
await stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("create — writes message + outbox row in one transaction", () => {
|
|
48
|
+
test("creates a message and returns MessageItem", async () => {
|
|
49
|
+
const item = await messageRepo.create(BASE_MESSAGE_INPUT);
|
|
50
|
+
assert.equal(item.messageId, MESSAGE_ID);
|
|
51
|
+
assert.equal(item.mailboxId, MAILBOX_ID);
|
|
52
|
+
assert.equal(item.uid, 42);
|
|
53
|
+
assert.equal(item.status, "active");
|
|
54
|
+
assert.equal(item.syncStatus, "synced");
|
|
55
|
+
assert.ok(typeof item.createdAt === "number");
|
|
56
|
+
assert.ok(typeof item.updatedAt === "number");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("writes an outbox row in the same transaction", async () => {
|
|
60
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
61
|
+
const { eq } = await import("drizzle-orm");
|
|
62
|
+
const rows = await db
|
|
63
|
+
.select()
|
|
64
|
+
.from(outboxTable)
|
|
65
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
66
|
+
assert.ok(rows.length >= 1, "should have at least 1 outbox row");
|
|
67
|
+
assert.equal(rows[0].event, "message.created");
|
|
68
|
+
assert.deepStrictEqual(rows[0].payload, { messageId: MESSAGE_ID });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("duplicate messageId throws CreateFailedConflictError and writes no extra outbox row", async () => {
|
|
72
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
73
|
+
const { eq } = await import("drizzle-orm");
|
|
74
|
+
|
|
75
|
+
const before = await db
|
|
76
|
+
.select()
|
|
77
|
+
.from(outboxTable)
|
|
78
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
79
|
+
|
|
80
|
+
await assert.rejects(
|
|
81
|
+
() => messageRepo.create(BASE_MESSAGE_INPUT),
|
|
82
|
+
(err: Error) => {
|
|
83
|
+
assert.equal(err.name, "CreateFailedConflictError");
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const after = await db
|
|
89
|
+
.select()
|
|
90
|
+
.from(outboxTable)
|
|
91
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
92
|
+
assert.equal(
|
|
93
|
+
after.length,
|
|
94
|
+
before.length,
|
|
95
|
+
"rolled-back create must not add an outbox row",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("upsert — idempotent re-create", () => {
|
|
101
|
+
test("second upsert returns the same message (no duplicate)", async () => {
|
|
102
|
+
const item = await messageRepo.upsert(BASE_MESSAGE_INPUT);
|
|
103
|
+
assert.equal(item.messageId, MESSAGE_ID);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("upsertWithStatus reports created=false for existing row", async () => {
|
|
107
|
+
const result = await messageRepo.upsertWithStatus(BASE_MESSAGE_INPUT);
|
|
108
|
+
assert.equal(result.created, false);
|
|
109
|
+
assert.equal(result.item.messageId, MESSAGE_ID);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("describe — MessageDescription shape (10 members)", () => {
|
|
114
|
+
before(async () => {
|
|
115
|
+
await envelopeRepo.upsertEnvelope({
|
|
116
|
+
envelopeId: ENVELOPE_ID,
|
|
117
|
+
messageId: MESSAGE_ID,
|
|
118
|
+
dateValue: NOW,
|
|
119
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
120
|
+
subject: "Test Subject",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("returns all 10 MessageDescription members", async () => {
|
|
125
|
+
const desc = await messageRepo.describe(MESSAGE_ID);
|
|
126
|
+
const keys = Object.keys(desc).sort();
|
|
127
|
+
assert.deepStrictEqual(keys, [
|
|
128
|
+
"bodyPart",
|
|
129
|
+
"bodyPartContent",
|
|
130
|
+
"bodyPartParameter",
|
|
131
|
+
"bodyPartStorage",
|
|
132
|
+
"envelope",
|
|
133
|
+
"envelopeAddress",
|
|
134
|
+
"message",
|
|
135
|
+
"messageFlag",
|
|
136
|
+
"messageReference",
|
|
137
|
+
"rawMessageStorage",
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("message array has 1 row with correct fields", async () => {
|
|
142
|
+
const desc = await messageRepo.describe(MESSAGE_ID);
|
|
143
|
+
assert.equal(desc.message.length, 1);
|
|
144
|
+
const msg = desc.message[0];
|
|
145
|
+
assert.equal(msg.messageId, MESSAGE_ID);
|
|
146
|
+
assert.equal(msg.mailboxId, MAILBOX_ID);
|
|
147
|
+
assert.equal(msg.uid, 42);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("envelope array has 1 row", async () => {
|
|
151
|
+
const desc = await messageRepo.describe(MESSAGE_ID);
|
|
152
|
+
assert.equal(desc.envelope.length, 1);
|
|
153
|
+
assert.equal(desc.envelope[0].envelopeId, ENVELOPE_ID);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("other arrays are empty (no related data inserted)", async () => {
|
|
157
|
+
const desc = await messageRepo.describe(MESSAGE_ID);
|
|
158
|
+
assert.equal(desc.messageFlag.length, 0);
|
|
159
|
+
assert.equal(desc.messageReference.length, 0);
|
|
160
|
+
assert.equal(desc.envelopeAddress.length, 0);
|
|
161
|
+
assert.equal(desc.bodyPart.length, 0);
|
|
162
|
+
assert.equal(desc.bodyPartParameter.length, 0);
|
|
163
|
+
assert.equal(desc.rawMessageStorage.length, 0);
|
|
164
|
+
assert.equal(desc.bodyPartStorage.length, 0);
|
|
165
|
+
assert.equal(desc.bodyPartContent.length, 0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("throws NotFoundError for unknown messageId", async () => {
|
|
169
|
+
await assert.rejects(
|
|
170
|
+
() => messageRepo.describe("00000000-0000-0000-9999-000000000000"),
|
|
171
|
+
(err: Error) => {
|
|
172
|
+
assert.equal(err.name, "NotFoundError");
|
|
173
|
+
return true;
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("updateForMove — relocates a message across mailboxes", () => {
|
|
180
|
+
const MOVE_MESSAGE_ID = "00000000-0000-0000-2222-000000000001";
|
|
181
|
+
const SOURCE_MAILBOX_ID = "00000000-0000-0000-2222-000000000002";
|
|
182
|
+
const DEST_MAILBOX_ID = "00000000-0000-0000-2222-000000000003";
|
|
183
|
+
|
|
184
|
+
before(async () => {
|
|
185
|
+
await messageRepo.create({
|
|
186
|
+
messageId: MOVE_MESSAGE_ID,
|
|
187
|
+
mailboxId: SOURCE_MAILBOX_ID,
|
|
188
|
+
uid: 7,
|
|
189
|
+
sequenceNumber: 1,
|
|
190
|
+
rfc822Size: 512,
|
|
191
|
+
internalDate: NOW,
|
|
192
|
+
envelopeId: deriveEnvelopeId(MOVE_MESSAGE_ID),
|
|
193
|
+
rootBodyPartId: deriveRootBodyPartId(MOVE_MESSAGE_ID),
|
|
194
|
+
status: "active" as const,
|
|
195
|
+
syncStatus: "synced" as const,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("returns the moved message with new mailbox and pending status", async () => {
|
|
200
|
+
const moved = await messageRepo.updateForMove(MOVE_MESSAGE_ID, {
|
|
201
|
+
mailboxId: DEST_MAILBOX_ID,
|
|
202
|
+
status: "moving",
|
|
203
|
+
syncStatus: "pending",
|
|
204
|
+
originalMailboxId: SOURCE_MAILBOX_ID,
|
|
205
|
+
originalUid: 7,
|
|
206
|
+
});
|
|
207
|
+
assert.equal(moved.mailboxId, DEST_MAILBOX_ID);
|
|
208
|
+
assert.equal(moved.status, "moving");
|
|
209
|
+
assert.equal(moved.syncStatus, "pending");
|
|
210
|
+
assert.equal(moved.originalMailboxId, SOURCE_MAILBOX_ID);
|
|
211
|
+
assert.equal(moved.originalUid, 7);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("the relocation is reflected in a subsequent read", async () => {
|
|
215
|
+
const read = await messageRepo.get(MOVE_MESSAGE_ID);
|
|
216
|
+
assert.equal(read.mailboxId, DEST_MAILBOX_ID);
|
|
217
|
+
assert.equal(read.status, "moving");
|
|
218
|
+
assert.equal(read.originalMailboxId, SOURCE_MAILBOX_ID);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("listByMailbox excludes the old mailbox and includes the new one", async () => {
|
|
222
|
+
const oldList = await messageRepo.listByMailbox(SOURCE_MAILBOX_ID);
|
|
223
|
+
assert.ok(
|
|
224
|
+
!oldList.items.some((m) => m.messageId === MOVE_MESSAGE_ID),
|
|
225
|
+
"moved message must not appear under the source mailbox",
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const newList = await messageRepo.listByMailbox(DEST_MAILBOX_ID);
|
|
229
|
+
assert.ok(
|
|
230
|
+
newList.items.some((m) => m.messageId === MOVE_MESSAGE_ID),
|
|
231
|
+
"moved message must appear under the destination mailbox",
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("updateForMove writes no move reindex event (completion-only)", async () => {
|
|
236
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
237
|
+
const { and, eq } = await import("drizzle-orm");
|
|
238
|
+
const rows = await db
|
|
239
|
+
.select()
|
|
240
|
+
.from(outboxTable)
|
|
241
|
+
.where(
|
|
242
|
+
and(
|
|
243
|
+
eq(outboxTable.messageId, MOVE_MESSAGE_ID),
|
|
244
|
+
eq(outboxTable.event, "message.moved"),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
assert.equal(rows.length, 0);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("updateUid completes the move and emits a move reindex event", async () => {
|
|
251
|
+
const completed = await messageRepo.updateUid(
|
|
252
|
+
MOVE_MESSAGE_ID,
|
|
253
|
+
99,
|
|
254
|
+
DEST_MAILBOX_ID,
|
|
255
|
+
);
|
|
256
|
+
assert.equal(completed.uid, 99);
|
|
257
|
+
assert.equal(completed.mailboxId, DEST_MAILBOX_ID);
|
|
258
|
+
assert.equal(completed.status, "active");
|
|
259
|
+
assert.equal(completed.syncStatus, "synced");
|
|
260
|
+
|
|
261
|
+
const read = await messageRepo.get(MOVE_MESSAGE_ID);
|
|
262
|
+
assert.equal(read.uid, 99);
|
|
263
|
+
assert.equal(read.status, "active");
|
|
264
|
+
assert.equal(read.syncStatus, "synced");
|
|
265
|
+
|
|
266
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
267
|
+
const { and, eq } = await import("drizzle-orm");
|
|
268
|
+
const rows = await db
|
|
269
|
+
.select()
|
|
270
|
+
.from(outboxTable)
|
|
271
|
+
.where(
|
|
272
|
+
and(
|
|
273
|
+
eq(outboxTable.messageId, MOVE_MESSAGE_ID),
|
|
274
|
+
eq(outboxTable.event, "message.moved"),
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
assert.equal(rows.length, 1, "one move reindex event was enqueued");
|
|
278
|
+
assert.deepStrictEqual(rows[0].payload, { messageId: MOVE_MESSAGE_ID });
|
|
279
|
+
assert.equal(rows[0].processedAt, null, "event starts undrained");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("updateForMove throws NotFoundError for unknown messageId", async () => {
|
|
283
|
+
await assert.rejects(
|
|
284
|
+
() =>
|
|
285
|
+
messageRepo.updateForMove("00000000-0000-0000-9999-000000000001", {
|
|
286
|
+
mailboxId: DEST_MAILBOX_ID,
|
|
287
|
+
}),
|
|
288
|
+
(err: Error) => {
|
|
289
|
+
assert.equal(err.name, "NotFoundError");
|
|
290
|
+
return true;
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("updateUid throws NotFoundError and rolls back its reindex event", async () => {
|
|
296
|
+
const UNKNOWN = "00000000-0000-0000-9999-000000000002";
|
|
297
|
+
await assert.rejects(
|
|
298
|
+
() => messageRepo.updateUid(UNKNOWN, 1, DEST_MAILBOX_ID),
|
|
299
|
+
(err: Error) => {
|
|
300
|
+
assert.equal(err.name, "NotFoundError");
|
|
301
|
+
return true;
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
306
|
+
const { eq } = await import("drizzle-orm");
|
|
307
|
+
const rows = await db
|
|
308
|
+
.select()
|
|
309
|
+
.from(outboxTable)
|
|
310
|
+
.where(eq(outboxTable.messageId, UNKNOWN));
|
|
311
|
+
assert.equal(rows.length, 0, "rolled-back move writes no outbox row");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("update — appends a body_synced outbox row per body write", () => {
|
|
316
|
+
const SYNC_MESSAGE_ID = "00000000-0000-0000-3333-000000000001";
|
|
317
|
+
const SYNC_MAILBOX_ID = "00000000-0000-0000-3333-000000000002";
|
|
318
|
+
|
|
319
|
+
const bodySyncedRows = async () => {
|
|
320
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
321
|
+
const { and, eq } = await import("drizzle-orm");
|
|
322
|
+
return db
|
|
323
|
+
.select()
|
|
324
|
+
.from(outboxTable)
|
|
325
|
+
.where(
|
|
326
|
+
and(
|
|
327
|
+
eq(outboxTable.messageId, SYNC_MESSAGE_ID),
|
|
328
|
+
eq(outboxTable.event, "message.body_synced"),
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
before(async () => {
|
|
334
|
+
await messageRepo.create({
|
|
335
|
+
messageId: SYNC_MESSAGE_ID,
|
|
336
|
+
mailboxId: SYNC_MAILBOX_ID,
|
|
337
|
+
uid: 11,
|
|
338
|
+
sequenceNumber: 1,
|
|
339
|
+
rfc822Size: 256,
|
|
340
|
+
internalDate: NOW,
|
|
341
|
+
envelopeId: deriveEnvelopeId(SYNC_MESSAGE_ID),
|
|
342
|
+
rootBodyPartId: deriveRootBodyPartId(SYNC_MESSAGE_ID),
|
|
343
|
+
status: "active" as const,
|
|
344
|
+
syncStatus: "synced" as const,
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a body write appends one body_synced event", async () => {
|
|
349
|
+
assert.equal((await bodySyncedRows()).length, 0, "none before sync");
|
|
350
|
+
await messageRepo.update(SYNC_MESSAGE_ID, {
|
|
351
|
+
bodyStorageKey: "s3://bucket/body-a.eml",
|
|
352
|
+
});
|
|
353
|
+
const rows = await bodySyncedRows();
|
|
354
|
+
assert.equal(rows.length, 1, "one row for one body write");
|
|
355
|
+
assert.deepStrictEqual(rows[0].payload, { messageId: SYNC_MESSAGE_ID });
|
|
356
|
+
assert.equal(rows[0].processedAt, null, "event starts undrained");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("each body write appends its own row (append-only, no suppression)", async () => {
|
|
360
|
+
await messageRepo.update(SYNC_MESSAGE_ID, {
|
|
361
|
+
bodyStorageKey: "s3://bucket/body-b.eml",
|
|
362
|
+
});
|
|
363
|
+
await messageRepo.update(SYNC_MESSAGE_ID, {
|
|
364
|
+
bodyStorageKey: "s3://bucket/body-c.eml",
|
|
365
|
+
});
|
|
366
|
+
assert.equal(
|
|
367
|
+
(await bodySyncedRows()).length,
|
|
368
|
+
3,
|
|
369
|
+
"three body writes leave three rows — coalescing is the worker's job",
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a classification-only update (no bodyStorageKey) appends nothing", async () => {
|
|
374
|
+
const before = (await bodySyncedRows()).length;
|
|
375
|
+
await messageRepo.update(SYNC_MESSAGE_ID, { category: "personal" });
|
|
376
|
+
assert.equal((await bodySyncedRows()).length, before);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("deleteMany — subtree delete + message.removed outbox", () => {
|
|
381
|
+
const DEL_MESSAGE_ID = "00000000-0000-0000-4444-000000000001";
|
|
382
|
+
const DEL_MAILBOX_ID = "00000000-0000-0000-4444-000000000002";
|
|
383
|
+
const DEL_ENVELOPE_ID = deriveEnvelopeId(DEL_MESSAGE_ID);
|
|
384
|
+
const DEL_ROOT_BODY_PART_ID = deriveRootBodyPartId(DEL_MESSAGE_ID);
|
|
385
|
+
const FLAG_ID = "00000000-0000-0000-4444-000000000003";
|
|
386
|
+
const BODY_PART_ID = "00000000-0000-0000-4444-000000000004";
|
|
387
|
+
|
|
388
|
+
before(async () => {
|
|
389
|
+
await messageRepo.create({
|
|
390
|
+
messageId: DEL_MESSAGE_ID,
|
|
391
|
+
mailboxId: DEL_MAILBOX_ID,
|
|
392
|
+
uid: 7,
|
|
393
|
+
sequenceNumber: 1,
|
|
394
|
+
rfc822Size: 10,
|
|
395
|
+
internalDate: NOW,
|
|
396
|
+
envelopeId: DEL_ENVELOPE_ID,
|
|
397
|
+
rootBodyPartId: DEL_ROOT_BODY_PART_ID,
|
|
398
|
+
status: "active",
|
|
399
|
+
syncStatus: "synced",
|
|
400
|
+
});
|
|
401
|
+
await envelopeRepo.upsertEnvelope({
|
|
402
|
+
envelopeId: DEL_ENVELOPE_ID,
|
|
403
|
+
messageId: DEL_MESSAGE_ID,
|
|
404
|
+
dateValue: NOW,
|
|
405
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
406
|
+
subject: "To be purged",
|
|
407
|
+
});
|
|
408
|
+
const { messageFlagTable, bodyPartTable } = await import(
|
|
409
|
+
"../schema/message-data.js"
|
|
410
|
+
);
|
|
411
|
+
await db.insert(messageFlagTable).values({
|
|
412
|
+
messageFlagId: FLAG_ID,
|
|
413
|
+
messageId: DEL_MESSAGE_ID,
|
|
414
|
+
flagName: "\\Seen",
|
|
415
|
+
setAt: NOW,
|
|
416
|
+
createdAt: NOW,
|
|
417
|
+
updatedAt: NOW,
|
|
418
|
+
});
|
|
419
|
+
await db.insert(bodyPartTable).values({
|
|
420
|
+
bodyPartId: BODY_PART_ID,
|
|
421
|
+
messageId: DEL_MESSAGE_ID,
|
|
422
|
+
partPath: "1",
|
|
423
|
+
mediaType: "TEXT",
|
|
424
|
+
mediaSubtype: "plain",
|
|
425
|
+
transferEncoding: "7BIT",
|
|
426
|
+
sizeOctets: 4,
|
|
427
|
+
isMultipart: false,
|
|
428
|
+
createdAt: NOW,
|
|
429
|
+
updatedAt: NOW,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("removes the message and every child row", async () => {
|
|
434
|
+
await messageRepo.deleteMany([DEL_MESSAGE_ID]);
|
|
435
|
+
|
|
436
|
+
await assert.rejects(() => messageRepo.get(DEL_MESSAGE_ID));
|
|
437
|
+
|
|
438
|
+
const { messageFlagTable, bodyPartTable, envelopeTable } = await import(
|
|
439
|
+
"../schema/message-data.js"
|
|
440
|
+
);
|
|
441
|
+
const { eq } = await import("drizzle-orm");
|
|
442
|
+
assert.equal(
|
|
443
|
+
(
|
|
444
|
+
await db
|
|
445
|
+
.select()
|
|
446
|
+
.from(messageFlagTable)
|
|
447
|
+
.where(eq(messageFlagTable.messageId, DEL_MESSAGE_ID))
|
|
448
|
+
).length,
|
|
449
|
+
0,
|
|
450
|
+
);
|
|
451
|
+
assert.equal(
|
|
452
|
+
(
|
|
453
|
+
await db
|
|
454
|
+
.select()
|
|
455
|
+
.from(bodyPartTable)
|
|
456
|
+
.where(eq(bodyPartTable.messageId, DEL_MESSAGE_ID))
|
|
457
|
+
).length,
|
|
458
|
+
0,
|
|
459
|
+
);
|
|
460
|
+
assert.equal(
|
|
461
|
+
(
|
|
462
|
+
await db
|
|
463
|
+
.select()
|
|
464
|
+
.from(envelopeTable)
|
|
465
|
+
.where(eq(envelopeTable.messageId, DEL_MESSAGE_ID))
|
|
466
|
+
).length,
|
|
467
|
+
0,
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("leaves exactly one undrained message.removed outbox row", async () => {
|
|
472
|
+
const { outboxTable } = await import("../schema/message-data.js");
|
|
473
|
+
const { eq } = await import("drizzle-orm");
|
|
474
|
+
const rows = await db
|
|
475
|
+
.select()
|
|
476
|
+
.from(outboxTable)
|
|
477
|
+
.where(eq(outboxTable.messageId, DEL_MESSAGE_ID));
|
|
478
|
+
assert.equal(rows.length, 1, "prior CDC rows are cleared");
|
|
479
|
+
assert.equal(rows[0].event, "message.removed");
|
|
480
|
+
assert.equal(rows[0].processedAt, null, "removal starts undrained");
|
|
481
|
+
assert.deepStrictEqual(rows[0].payload, { messageId: DEL_MESSAGE_ID });
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("deleteMany([]) is a no-op", async () => {
|
|
485
|
+
await messageRepo.deleteMany([]);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
});
|