@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,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
type MessageDataSchema,
|
|
5
|
+
messageDataSchema,
|
|
6
|
+
} from "../schema/message-data.js";
|
|
7
|
+
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
8
|
+
import { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
9
|
+
|
|
10
|
+
// Envelope repo on sqlite (RFC 036 D1): the onConflictDoUpdate upsert path and
|
|
11
|
+
// the transactional body-part upsert both run on better-sqlite3 unchanged.
|
|
12
|
+
|
|
13
|
+
const MESSAGE_ID = "00000000-0000-0000-4444-000000000001";
|
|
14
|
+
|
|
15
|
+
describe("DrizzleEnvelopeRepository (sqlite)", () => {
|
|
16
|
+
let db: SqliteTestDb<MessageDataSchema>;
|
|
17
|
+
let close: () => Promise<void>;
|
|
18
|
+
let repo: DrizzleEnvelopeRepository;
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
({ db, close } = await createSqliteTestDb(messageDataSchema));
|
|
22
|
+
repo = new DrizzleEnvelopeRepository(db);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
after(async () => {
|
|
26
|
+
await close();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("upsertEnvelope inserts then updates on conflict", async () => {
|
|
30
|
+
const created = await repo.upsertEnvelope({
|
|
31
|
+
envelopeId: "ignored-derived",
|
|
32
|
+
messageId: MESSAGE_ID,
|
|
33
|
+
dateValue: 1700000000000,
|
|
34
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
35
|
+
subject: "First",
|
|
36
|
+
});
|
|
37
|
+
assert.equal(created.subject, "First");
|
|
38
|
+
|
|
39
|
+
const updated = await repo.upsertEnvelope({
|
|
40
|
+
envelopeId: "ignored-derived",
|
|
41
|
+
messageId: MESSAGE_ID,
|
|
42
|
+
dateValue: 1700000000000,
|
|
43
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
44
|
+
subject: "Second",
|
|
45
|
+
});
|
|
46
|
+
assert.equal(updated.envelopeId, created.envelopeId);
|
|
47
|
+
assert.equal(updated.subject, "Second");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("upsertBodyParts writes parts and parameters in one transaction", async () => {
|
|
51
|
+
await repo.upsertBodyParts(MESSAGE_ID, [
|
|
52
|
+
{
|
|
53
|
+
partPath: "1",
|
|
54
|
+
parentPartPath: null,
|
|
55
|
+
mediaType: "TEXT",
|
|
56
|
+
mediaSubtype: "plain",
|
|
57
|
+
transferEncoding: "7BIT",
|
|
58
|
+
sizeOctets: 128,
|
|
59
|
+
isMultipart: false,
|
|
60
|
+
parameters: [{ parameterName: "charset", parameterValue: "utf-8" }],
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const data = await repo.getMessageData(MESSAGE_ID);
|
|
65
|
+
assert.ok(data.bodyPart.length >= 1);
|
|
66
|
+
assert.ok(
|
|
67
|
+
data.bodyPartParameter.some(
|
|
68
|
+
(p) => p.parameterName === "charset" && p.parameterValue === "utf-8",
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("re-upserting the same partPath does not duplicate the row", async () => {
|
|
74
|
+
await repo.upsertBodyParts(MESSAGE_ID, [
|
|
75
|
+
{
|
|
76
|
+
partPath: "1",
|
|
77
|
+
parentPartPath: null,
|
|
78
|
+
mediaType: "TEXT",
|
|
79
|
+
mediaSubtype: "plain",
|
|
80
|
+
transferEncoding: "7BIT",
|
|
81
|
+
sizeOctets: 256,
|
|
82
|
+
isMultipart: false,
|
|
83
|
+
parameters: [],
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
const data = await repo.getMessageData(MESSAGE_ID);
|
|
87
|
+
const parts = data.bodyPart.filter((p) => p.partPath === "1");
|
|
88
|
+
assert.equal(
|
|
89
|
+
parts.length,
|
|
90
|
+
1,
|
|
91
|
+
"conflict update must not insert a second row",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import type { BodyPartUpsertInput } from "@remit/data-ports";
|
|
4
|
+
import { envelopeId as deriveEnvelopeId } from "../id.js";
|
|
5
|
+
import { DrizzleEnvelopeRepository } from "./envelope.js";
|
|
6
|
+
import { createTestDb, type TestDb } from "./test-helpers.js";
|
|
7
|
+
|
|
8
|
+
describe("DrizzleEnvelopeRepository", () => {
|
|
9
|
+
let db: TestDb;
|
|
10
|
+
let stop: () => Promise<void>;
|
|
11
|
+
let repo: DrizzleEnvelopeRepository;
|
|
12
|
+
|
|
13
|
+
const MESSAGE_ID = "00000000-0000-0000-0000-000000000001";
|
|
14
|
+
const ENVELOPE_ID = deriveEnvelopeId(MESSAGE_ID);
|
|
15
|
+
const ADDRESS_ID = "00000000-0000-0000-0000-000000000003";
|
|
16
|
+
const ENVELOPE_ADDR_ID = "00000000-0000-0000-0000-000000000004";
|
|
17
|
+
const NOW = 1700000000000;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
({ db, stop } = await createTestDb());
|
|
21
|
+
repo = new DrizzleEnvelopeRepository(db);
|
|
22
|
+
|
|
23
|
+
await repo.upsertEnvelope({
|
|
24
|
+
envelopeId: ENVELOPE_ID,
|
|
25
|
+
messageId: MESSAGE_ID,
|
|
26
|
+
dateValue: NOW,
|
|
27
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
28
|
+
subject: "Hello World",
|
|
29
|
+
messageIdValue: "<test@example.com>",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
after(async () => {
|
|
34
|
+
await stop();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("getMessageData — MessageData shape (8 members)", () => {
|
|
38
|
+
test("returns all 8 collection members", async () => {
|
|
39
|
+
const data = await repo.getMessageData(MESSAGE_ID);
|
|
40
|
+
const keys = Object.keys(data).sort();
|
|
41
|
+
assert.deepStrictEqual(keys, [
|
|
42
|
+
"bodyPart",
|
|
43
|
+
"bodyPartContent",
|
|
44
|
+
"bodyPartParameter",
|
|
45
|
+
"bodyPartStorage",
|
|
46
|
+
"envelope",
|
|
47
|
+
"envelopeAddress",
|
|
48
|
+
"messageReference",
|
|
49
|
+
"rawMessageStorage",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("envelope has 1 row with correct fields", async () => {
|
|
54
|
+
const data = await repo.getMessageData(MESSAGE_ID);
|
|
55
|
+
assert.equal(data.envelope.length, 1);
|
|
56
|
+
const env = data.envelope[0];
|
|
57
|
+
assert.equal(env.envelopeId, ENVELOPE_ID);
|
|
58
|
+
assert.equal(env.messageId, MESSAGE_ID);
|
|
59
|
+
assert.equal(env.dateValue, NOW);
|
|
60
|
+
assert.equal(env.dateRaw, "Mon, 14 Nov 2023 08:00:00 +0000");
|
|
61
|
+
assert.equal(env.subject, "Hello World");
|
|
62
|
+
assert.equal(env.messageIdValue, "<test@example.com>");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("empty collections return empty arrays", async () => {
|
|
66
|
+
const data = await repo.getMessageData(MESSAGE_ID);
|
|
67
|
+
assert.equal(data.messageReference.length, 0);
|
|
68
|
+
assert.equal(data.envelopeAddress.length, 0);
|
|
69
|
+
assert.equal(data.bodyPart.length, 0);
|
|
70
|
+
assert.equal(data.bodyPartParameter.length, 0);
|
|
71
|
+
assert.equal(data.rawMessageStorage.length, 0);
|
|
72
|
+
assert.equal(data.bodyPartStorage.length, 0);
|
|
73
|
+
assert.equal(data.bodyPartContent.length, 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("throws NotFoundError for unknown messageId", async () => {
|
|
77
|
+
await assert.rejects(
|
|
78
|
+
() => repo.getMessageData("00000000-0000-0000-0000-999999999999"),
|
|
79
|
+
(err: Error) => {
|
|
80
|
+
assert.equal(err.name, "NotFoundError");
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("upsertBodyParts — idempotent single-transaction write", () => {
|
|
88
|
+
const PART: BodyPartUpsertInput = {
|
|
89
|
+
partPath: "1",
|
|
90
|
+
parentPartPath: null,
|
|
91
|
+
mediaType: "TEXT",
|
|
92
|
+
mediaSubtype: "plain",
|
|
93
|
+
transferEncoding: "7BIT",
|
|
94
|
+
sizeOctets: 42,
|
|
95
|
+
isMultipart: false,
|
|
96
|
+
parameters: [{ parameterName: "charset", parameterValue: "utf-8" }],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
test("upserts body parts and parameters", async () => {
|
|
100
|
+
await repo.upsertBodyParts(MESSAGE_ID, [PART]);
|
|
101
|
+
const parts = await repo.listBodyParts(MESSAGE_ID);
|
|
102
|
+
assert.equal(parts.length, 1);
|
|
103
|
+
assert.equal(parts[0].partPath, "1");
|
|
104
|
+
assert.equal(parts[0].mediaType, "TEXT");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("idempotent — second upsert does not duplicate", async () => {
|
|
108
|
+
await repo.upsertBodyParts(MESSAGE_ID, [PART]);
|
|
109
|
+
await repo.upsertBodyParts(MESSAGE_ID, [PART]);
|
|
110
|
+
const parts = await repo.listBodyParts(MESSAGE_ID);
|
|
111
|
+
assert.equal(
|
|
112
|
+
parts.length,
|
|
113
|
+
1,
|
|
114
|
+
"should still have exactly 1 body part after re-upsert",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("duplicate partPath in one call does not throw and writes one row", async () => {
|
|
119
|
+
// The MIME walker can hand back two nodes with the same synthetic
|
|
120
|
+
// partPath (e.g. the inner body of a message/rfc822 attachment),
|
|
121
|
+
// yielding duplicate bodyPartIds in a single insert. A single
|
|
122
|
+
// INSERT ... ON CONFLICT DO UPDATE would raise SQLSTATE 21000 unless
|
|
123
|
+
// we dedupe first.
|
|
124
|
+
const DUP_MSG_ID = "00000000-0000-0000-0000-000000000077";
|
|
125
|
+
const duped: BodyPartUpsertInput[] = [
|
|
126
|
+
{
|
|
127
|
+
partPath: "2",
|
|
128
|
+
parentPartPath: null,
|
|
129
|
+
mediaType: "MESSAGE",
|
|
130
|
+
mediaSubtype: "rfc822",
|
|
131
|
+
transferEncoding: "7BIT",
|
|
132
|
+
sizeOctets: 10,
|
|
133
|
+
isMultipart: false,
|
|
134
|
+
parameters: [{ parameterName: "charset", parameterValue: "utf-8" }],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
partPath: "2",
|
|
138
|
+
parentPartPath: null,
|
|
139
|
+
mediaType: "TEXT",
|
|
140
|
+
mediaSubtype: "plain",
|
|
141
|
+
transferEncoding: "7BIT",
|
|
142
|
+
sizeOctets: 20,
|
|
143
|
+
isMultipart: false,
|
|
144
|
+
parameters: [{ parameterName: "charset", parameterValue: "latin1" }],
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
await assert.doesNotReject(() => repo.upsertBodyParts(DUP_MSG_ID, duped));
|
|
149
|
+
|
|
150
|
+
const parts = await repo.listBodyParts(DUP_MSG_ID);
|
|
151
|
+
const matching = parts.filter((p) => p.partPath === "2");
|
|
152
|
+
assert.equal(
|
|
153
|
+
matching.length,
|
|
154
|
+
1,
|
|
155
|
+
"duplicate partPath must collapse to exactly one row",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("deleteManyEnvelopes — single-transaction delete", () => {
|
|
161
|
+
const DEL_MSG_ID = "00000000-0000-0000-0000-000000000099";
|
|
162
|
+
const DEL_ENV_ID = deriveEnvelopeId(DEL_MSG_ID);
|
|
163
|
+
|
|
164
|
+
before(async () => {
|
|
165
|
+
await repo.upsertEnvelope({
|
|
166
|
+
envelopeId: DEL_ENV_ID,
|
|
167
|
+
messageId: DEL_MSG_ID,
|
|
168
|
+
dateValue: NOW,
|
|
169
|
+
dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("deletes the specified envelopes", async () => {
|
|
174
|
+
await repo.deleteManyEnvelopes([DEL_ENV_ID]);
|
|
175
|
+
await assert.rejects(
|
|
176
|
+
() => repo.getMessageData(DEL_MSG_ID),
|
|
177
|
+
(err: Error) => {
|
|
178
|
+
assert.equal(err.name, "NotFoundError");
|
|
179
|
+
return true;
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("no-op on empty array", async () => {
|
|
185
|
+
await assert.doesNotReject(() => repo.deleteManyEnvelopes([]));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("envelopeAddress — verifies envelopeAddress in getMessageData", () => {
|
|
190
|
+
before(async () => {
|
|
191
|
+
const { envelopeAddressTable } = await import(
|
|
192
|
+
"../schema/message-data.js"
|
|
193
|
+
);
|
|
194
|
+
await db
|
|
195
|
+
.insert(envelopeAddressTable)
|
|
196
|
+
.values({
|
|
197
|
+
envelopeAddressId: ENVELOPE_ADDR_ID,
|
|
198
|
+
messageId: MESSAGE_ID,
|
|
199
|
+
addressId: ADDRESS_ID,
|
|
200
|
+
normalizedEmail: "alice@example.com",
|
|
201
|
+
displayName: "Alice",
|
|
202
|
+
addressRole: "from",
|
|
203
|
+
addressOrder: 0,
|
|
204
|
+
createdAt: NOW,
|
|
205
|
+
updatedAt: NOW,
|
|
206
|
+
})
|
|
207
|
+
.onConflictDoNothing();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("getMessageData includes envelopeAddress rows", async () => {
|
|
211
|
+
const data = await repo.getMessageData(MESSAGE_ID);
|
|
212
|
+
assert.ok(
|
|
213
|
+
data.envelopeAddress.length >= 1,
|
|
214
|
+
"should have at least one address",
|
|
215
|
+
);
|
|
216
|
+
const addr = data.envelopeAddress.find(
|
|
217
|
+
(a) => a.envelopeAddressId === ENVELOPE_ADDR_ID,
|
|
218
|
+
);
|
|
219
|
+
assert.ok(addr, "should find the inserted address");
|
|
220
|
+
assert.equal(addr.normalizedEmail, "alice@example.com");
|
|
221
|
+
assert.equal(addr.displayName, "Alice");
|
|
222
|
+
assert.equal(addr.addressRole, "from");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BodyPartContentUpsertInput,
|
|
3
|
+
BodyPartItem,
|
|
4
|
+
BodyPartUpsertInput,
|
|
5
|
+
CreateEnvelopeInput,
|
|
6
|
+
EnvelopeItem,
|
|
7
|
+
IEnvelopeRepository,
|
|
8
|
+
MessageData,
|
|
9
|
+
UpdateEnvelopeInput,
|
|
10
|
+
} from "@remit/data-ports";
|
|
11
|
+
import { eq, inArray } from "drizzle-orm";
|
|
12
|
+
import type { Db } from "../db.js";
|
|
13
|
+
import {
|
|
14
|
+
CreateFailedConflictError,
|
|
15
|
+
isUniqueViolation,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
} from "../error.js";
|
|
18
|
+
import {
|
|
19
|
+
bodyPartContentId as deriveBodyPartContentId,
|
|
20
|
+
bodyPartId as deriveBodyPartId,
|
|
21
|
+
bodyPartParameterId as deriveBodyPartParameterId,
|
|
22
|
+
envelopeId as deriveEnvelopeId,
|
|
23
|
+
} from "../id.js";
|
|
24
|
+
import {
|
|
25
|
+
bodyPartContentTable,
|
|
26
|
+
bodyPartParameterTable,
|
|
27
|
+
bodyPartStorageTable,
|
|
28
|
+
bodyPartTable,
|
|
29
|
+
envelopeAddressTable,
|
|
30
|
+
envelopeTable,
|
|
31
|
+
type MessageDataSchema,
|
|
32
|
+
messageReferenceTable,
|
|
33
|
+
rawMessageStorageTable,
|
|
34
|
+
} from "../schema/message-data.js";
|
|
35
|
+
import { runInTransaction } from "../tx.js";
|
|
36
|
+
import {
|
|
37
|
+
toBodyPartContentItem,
|
|
38
|
+
toBodyPartItem,
|
|
39
|
+
toBodyPartParameterItem,
|
|
40
|
+
toBodyPartStorageItem,
|
|
41
|
+
toEnvelopeAddressItem,
|
|
42
|
+
toEnvelopeItem,
|
|
43
|
+
toMessageReferenceItem,
|
|
44
|
+
toRawMessageStorageItem,
|
|
45
|
+
} from "./mappers.js";
|
|
46
|
+
|
|
47
|
+
type DB = Db<MessageDataSchema>;
|
|
48
|
+
|
|
49
|
+
function dedupeById<T>(rows: T[], getId: (row: T) => string): T[] {
|
|
50
|
+
const byId = new Map<string, T>();
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
byId.set(getId(row), row);
|
|
53
|
+
}
|
|
54
|
+
return [...byId.values()];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
|
|
58
|
+
constructor(private db: DB) {}
|
|
59
|
+
|
|
60
|
+
async getMessageData(messageId: string): Promise<MessageData> {
|
|
61
|
+
const [
|
|
62
|
+
envelopes,
|
|
63
|
+
messageReferences,
|
|
64
|
+
envelopeAddresses,
|
|
65
|
+
bodyParts,
|
|
66
|
+
bodyPartParameters,
|
|
67
|
+
rawMessageStorages,
|
|
68
|
+
bodyPartStorages,
|
|
69
|
+
bodyPartContents,
|
|
70
|
+
] = await Promise.all([
|
|
71
|
+
this.db
|
|
72
|
+
.select()
|
|
73
|
+
.from(envelopeTable)
|
|
74
|
+
.where(eq(envelopeTable.messageId, messageId)),
|
|
75
|
+
this.db
|
|
76
|
+
.select()
|
|
77
|
+
.from(messageReferenceTable)
|
|
78
|
+
.where(eq(messageReferenceTable.messageId, messageId)),
|
|
79
|
+
this.db
|
|
80
|
+
.select()
|
|
81
|
+
.from(envelopeAddressTable)
|
|
82
|
+
.where(eq(envelopeAddressTable.messageId, messageId)),
|
|
83
|
+
this.db
|
|
84
|
+
.select()
|
|
85
|
+
.from(bodyPartTable)
|
|
86
|
+
.where(eq(bodyPartTable.messageId, messageId)),
|
|
87
|
+
this.db
|
|
88
|
+
.select()
|
|
89
|
+
.from(bodyPartParameterTable)
|
|
90
|
+
.where(eq(bodyPartParameterTable.messageId, messageId)),
|
|
91
|
+
this.db
|
|
92
|
+
.select()
|
|
93
|
+
.from(rawMessageStorageTable)
|
|
94
|
+
.where(eq(rawMessageStorageTable.messageId, messageId)),
|
|
95
|
+
this.db
|
|
96
|
+
.select()
|
|
97
|
+
.from(bodyPartStorageTable)
|
|
98
|
+
.where(eq(bodyPartStorageTable.messageId, messageId)),
|
|
99
|
+
this.db
|
|
100
|
+
.select()
|
|
101
|
+
.from(bodyPartContentTable)
|
|
102
|
+
.where(eq(bodyPartContentTable.messageId, messageId)),
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
if (envelopes.length === 0) {
|
|
106
|
+
throw new NotFoundError(`Message data not found: ${messageId}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
envelope: envelopes.map(toEnvelopeItem),
|
|
111
|
+
messageReference: messageReferences.map(toMessageReferenceItem),
|
|
112
|
+
envelopeAddress: envelopeAddresses.map(toEnvelopeAddressItem),
|
|
113
|
+
bodyPart: bodyParts.map(toBodyPartItem),
|
|
114
|
+
bodyPartParameter: bodyPartParameters.map(toBodyPartParameterItem),
|
|
115
|
+
rawMessageStorage: rawMessageStorages.map(toRawMessageStorageItem),
|
|
116
|
+
bodyPartStorage: bodyPartStorages.map(toBodyPartStorageItem),
|
|
117
|
+
bodyPartContent: bodyPartContents.map(toBodyPartContentItem),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async upsertBodyParts(
|
|
122
|
+
messageId: string,
|
|
123
|
+
parts: BodyPartUpsertInput[],
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
if (parts.length === 0) return;
|
|
126
|
+
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
|
|
129
|
+
const bodyPartRows = parts.map((part) => ({
|
|
130
|
+
bodyPartId: deriveBodyPartId(messageId, part.partPath),
|
|
131
|
+
messageId,
|
|
132
|
+
partPath: part.partPath,
|
|
133
|
+
mediaType: part.mediaType,
|
|
134
|
+
mediaSubtype: part.mediaSubtype,
|
|
135
|
+
transferEncoding: part.transferEncoding,
|
|
136
|
+
sizeOctets: part.sizeOctets,
|
|
137
|
+
isMultipart: part.isMultipart,
|
|
138
|
+
parentBodyPartId:
|
|
139
|
+
part.parentPartPath !== null
|
|
140
|
+
? deriveBodyPartId(messageId, part.parentPartPath)
|
|
141
|
+
: null,
|
|
142
|
+
contentId: part.contentId ?? null,
|
|
143
|
+
contentDescription: part.contentDescription ?? null,
|
|
144
|
+
lineCount: part.lineCount ?? null,
|
|
145
|
+
md5Hash: part.md5Hash ?? null,
|
|
146
|
+
disposition: part.disposition ?? null,
|
|
147
|
+
dispositionFilename: part.dispositionFilename ?? null,
|
|
148
|
+
language: part.language ?? null,
|
|
149
|
+
location: part.location ?? null,
|
|
150
|
+
multipartSubtype: part.multipartSubtype ?? null,
|
|
151
|
+
createdAt: now,
|
|
152
|
+
updatedAt: now,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
const parameterRows = parts.flatMap((part) =>
|
|
156
|
+
part.parameters.map((param) => ({
|
|
157
|
+
bodyPartParameterId: deriveBodyPartParameterId(
|
|
158
|
+
messageId,
|
|
159
|
+
part.partPath,
|
|
160
|
+
param.parameterName,
|
|
161
|
+
),
|
|
162
|
+
messageId,
|
|
163
|
+
bodyPartId: deriveBodyPartId(messageId, part.partPath),
|
|
164
|
+
parameterName: param.parameterName,
|
|
165
|
+
parameterValue: param.parameterValue,
|
|
166
|
+
createdAt: now,
|
|
167
|
+
updatedAt: now,
|
|
168
|
+
})),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// The MIME walker assigns synthetic partPaths for nodes imapflow returns
|
|
172
|
+
// without a `part` field (e.g. the inner body of a message/rfc822
|
|
173
|
+
// attachment), which can produce duplicate ids. A single
|
|
174
|
+
// INSERT ... ON CONFLICT DO UPDATE fails (SQLSTATE 21000) if the same
|
|
175
|
+
// conflict-target id appears twice, so dedupe first (last-write-wins),
|
|
176
|
+
// matching the ElectroDB `seen` guard.
|
|
177
|
+
const dedupedBodyPartRows = dedupeById(bodyPartRows, (r) => r.bodyPartId);
|
|
178
|
+
const dedupedParameterRows = dedupeById(
|
|
179
|
+
parameterRows,
|
|
180
|
+
(r) => r.bodyPartParameterId,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await runInTransaction(this.db, async (tx) => {
|
|
184
|
+
await tx
|
|
185
|
+
.insert(bodyPartTable)
|
|
186
|
+
.values(dedupedBodyPartRows)
|
|
187
|
+
.onConflictDoUpdate({
|
|
188
|
+
target: bodyPartTable.bodyPartId,
|
|
189
|
+
set: { updatedAt: now },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (dedupedParameterRows.length > 0) {
|
|
193
|
+
await tx
|
|
194
|
+
.insert(bodyPartParameterTable)
|
|
195
|
+
.values(dedupedParameterRows)
|
|
196
|
+
.onConflictDoUpdate({
|
|
197
|
+
target: bodyPartParameterTable.bodyPartParameterId,
|
|
198
|
+
set: {
|
|
199
|
+
parameterValue: bodyPartParameterTable.parameterValue,
|
|
200
|
+
updatedAt: now,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async deleteManyEnvelopes(envelopeIds: string[]): Promise<void> {
|
|
208
|
+
if (envelopeIds.length === 0) return;
|
|
209
|
+
|
|
210
|
+
await runInTransaction(this.db, async (tx) => {
|
|
211
|
+
await tx
|
|
212
|
+
.delete(envelopeTable)
|
|
213
|
+
.where(inArray(envelopeTable.envelopeId, envelopeIds));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async createEnvelope(input: CreateEnvelopeInput): Promise<EnvelopeItem> {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const row = {
|
|
220
|
+
...input,
|
|
221
|
+
envelopeId: deriveEnvelopeId(input.messageId),
|
|
222
|
+
subject: input.subject ?? null,
|
|
223
|
+
messageIdValue: input.messageIdValue ?? null,
|
|
224
|
+
createdAt: now,
|
|
225
|
+
updatedAt: now,
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
await this.db.insert(envelopeTable).values(row);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (isUniqueViolation(error)) {
|
|
231
|
+
throw new CreateFailedConflictError("Envelope", input);
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return toEnvelopeItem(row as typeof envelopeTable.$inferSelect);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async upsertEnvelope(input: CreateEnvelopeInput): Promise<EnvelopeItem> {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const row = {
|
|
241
|
+
...input,
|
|
242
|
+
envelopeId: deriveEnvelopeId(input.messageId),
|
|
243
|
+
subject: input.subject ?? null,
|
|
244
|
+
messageIdValue: input.messageIdValue ?? null,
|
|
245
|
+
createdAt: now,
|
|
246
|
+
updatedAt: now,
|
|
247
|
+
};
|
|
248
|
+
await this.db
|
|
249
|
+
.insert(envelopeTable)
|
|
250
|
+
.values(row)
|
|
251
|
+
.onConflictDoUpdate({
|
|
252
|
+
target: envelopeTable.envelopeId,
|
|
253
|
+
set: {
|
|
254
|
+
subject: row.subject,
|
|
255
|
+
messageIdValue: row.messageIdValue,
|
|
256
|
+
updatedAt: now,
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
return toEnvelopeItem(row as typeof envelopeTable.$inferSelect);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getEnvelope(envelopeId: string): Promise<EnvelopeItem>;
|
|
263
|
+
async getEnvelope(envelopeIds: string[]): Promise<EnvelopeItem[]>;
|
|
264
|
+
async getEnvelope(
|
|
265
|
+
envelopeId: string | string[],
|
|
266
|
+
): Promise<EnvelopeItem | EnvelopeItem[]> {
|
|
267
|
+
if (Array.isArray(envelopeId)) {
|
|
268
|
+
if (envelopeId.length === 0) return [];
|
|
269
|
+
const rows = await this.db
|
|
270
|
+
.select()
|
|
271
|
+
.from(envelopeTable)
|
|
272
|
+
.where(inArray(envelopeTable.envelopeId, envelopeId));
|
|
273
|
+
return rows.map(toEnvelopeItem);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const rows = await this.db
|
|
277
|
+
.select()
|
|
278
|
+
.from(envelopeTable)
|
|
279
|
+
.where(eq(envelopeTable.envelopeId, envelopeId));
|
|
280
|
+
if (rows.length === 0) {
|
|
281
|
+
throw new NotFoundError(`Envelope not found: ${envelopeId}`);
|
|
282
|
+
}
|
|
283
|
+
return toEnvelopeItem(rows[0]);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async updateEnvelope(
|
|
287
|
+
envelopeId: string,
|
|
288
|
+
input: UpdateEnvelopeInput,
|
|
289
|
+
): Promise<EnvelopeItem> {
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
await this.db
|
|
292
|
+
.update(envelopeTable)
|
|
293
|
+
.set({ ...input, updatedAt: now })
|
|
294
|
+
.where(eq(envelopeTable.envelopeId, envelopeId));
|
|
295
|
+
return this.getEnvelope(envelopeId);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async deleteEnvelope(envelopeId: string): Promise<void> {
|
|
299
|
+
await this.db
|
|
300
|
+
.delete(envelopeTable)
|
|
301
|
+
.where(eq(envelopeTable.envelopeId, envelopeId));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async upsertBodyPartContents(
|
|
305
|
+
messageId: string,
|
|
306
|
+
contents: BodyPartContentUpsertInput[],
|
|
307
|
+
): Promise<void> {
|
|
308
|
+
if (contents.length === 0) return;
|
|
309
|
+
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
|
|
312
|
+
const rows = contents.map((c) => ({
|
|
313
|
+
bodyPartContentId: deriveBodyPartContentId(messageId, c.bodyPartId),
|
|
314
|
+
messageId,
|
|
315
|
+
bodyPartId: c.bodyPartId,
|
|
316
|
+
content: c.content,
|
|
317
|
+
contentLength: c.content.length,
|
|
318
|
+
createdAt: now,
|
|
319
|
+
updatedAt: now,
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
await this.db
|
|
323
|
+
.insert(bodyPartContentTable)
|
|
324
|
+
.values(rows)
|
|
325
|
+
.onConflictDoUpdate({
|
|
326
|
+
target: bodyPartContentTable.bodyPartContentId,
|
|
327
|
+
set: {
|
|
328
|
+
content: bodyPartContentTable.content,
|
|
329
|
+
contentLength: bodyPartContentTable.contentLength,
|
|
330
|
+
updatedAt: now,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async listBodyParts(messageId: string): Promise<BodyPartItem[]> {
|
|
336
|
+
const rows = await this.db
|
|
337
|
+
.select()
|
|
338
|
+
.from(bodyPartTable)
|
|
339
|
+
.where(eq(bodyPartTable.messageId, messageId));
|
|
340
|
+
return rows.map(toBodyPartItem);
|
|
341
|
+
}
|
|
342
|
+
}
|