@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,162 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
CreateMessageFlagInput,
|
|
4
|
+
IMessageFlagRepository,
|
|
5
|
+
MessageFlagItem,
|
|
6
|
+
} from "@remit/data-ports";
|
|
7
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
8
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
|
+
import { NotFoundError } from "../error.js";
|
|
10
|
+
import {
|
|
11
|
+
type MessageDataSchema,
|
|
12
|
+
messageFlagTable,
|
|
13
|
+
} from "../schema/message-data.js";
|
|
14
|
+
|
|
15
|
+
type DB = NodePgDatabase<MessageDataSchema>;
|
|
16
|
+
|
|
17
|
+
function rowToMessageFlag(
|
|
18
|
+
row: typeof messageFlagTable.$inferSelect,
|
|
19
|
+
): MessageFlagItem {
|
|
20
|
+
return {
|
|
21
|
+
messageFlagId: row.messageFlagId,
|
|
22
|
+
messageId: row.messageId,
|
|
23
|
+
flagName: row.flagName,
|
|
24
|
+
setAt: row.setAt,
|
|
25
|
+
createdAt: row.createdAt,
|
|
26
|
+
updatedAt: row.updatedAt,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class DrizzleMessageFlagRepository implements IMessageFlagRepository {
|
|
31
|
+
constructor(private db: DB) {}
|
|
32
|
+
|
|
33
|
+
async create(input: CreateMessageFlagInput): Promise<MessageFlagItem> {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const messageFlagId = randomUUID();
|
|
36
|
+
const [row] = await this.db
|
|
37
|
+
.insert(messageFlagTable)
|
|
38
|
+
.values({
|
|
39
|
+
messageFlagId,
|
|
40
|
+
messageId: input.messageId,
|
|
41
|
+
flagName: input.flagName,
|
|
42
|
+
setAt: input.setAt,
|
|
43
|
+
createdAt: now,
|
|
44
|
+
updatedAt: now,
|
|
45
|
+
})
|
|
46
|
+
.returning();
|
|
47
|
+
if (!row) throw new NotFoundError(`MessageFlag create failed`);
|
|
48
|
+
return rowToMessageFlag(row);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async get(messageFlagId: string): Promise<MessageFlagItem>;
|
|
52
|
+
async get(messageFlagIds: string[]): Promise<MessageFlagItem[]>;
|
|
53
|
+
async get(
|
|
54
|
+
idOrIds: string | string[],
|
|
55
|
+
): Promise<MessageFlagItem | MessageFlagItem[]> {
|
|
56
|
+
if (Array.isArray(idOrIds)) {
|
|
57
|
+
if (idOrIds.length === 0) return [];
|
|
58
|
+
const rows = await this.db
|
|
59
|
+
.select()
|
|
60
|
+
.from(messageFlagTable)
|
|
61
|
+
.where(inArray(messageFlagTable.messageFlagId, idOrIds));
|
|
62
|
+
return rows.map(rowToMessageFlag);
|
|
63
|
+
}
|
|
64
|
+
const [row] = await this.db
|
|
65
|
+
.select()
|
|
66
|
+
.from(messageFlagTable)
|
|
67
|
+
.where(eq(messageFlagTable.messageFlagId, idOrIds));
|
|
68
|
+
if (!row) throw new NotFoundError(`MessageFlag not found: ${idOrIds}`);
|
|
69
|
+
return rowToMessageFlag(row);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async delete(messageFlagId: string): Promise<void> {
|
|
73
|
+
await this.db
|
|
74
|
+
.delete(messageFlagTable)
|
|
75
|
+
.where(eq(messageFlagTable.messageFlagId, messageFlagId));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deleteMany(messageFlagIds: string[]): Promise<void> {
|
|
79
|
+
if (messageFlagIds.length === 0) return;
|
|
80
|
+
await this.db
|
|
81
|
+
.delete(messageFlagTable)
|
|
82
|
+
.where(inArray(messageFlagTable.messageFlagId, messageFlagIds));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getFlags(messageId: string): Promise<MessageFlagItem[]> {
|
|
86
|
+
const rows = await this.db
|
|
87
|
+
.select()
|
|
88
|
+
.from(messageFlagTable)
|
|
89
|
+
.where(eq(messageFlagTable.messageId, messageId));
|
|
90
|
+
return rows.map(rowToMessageFlag);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async hasFlag(messageId: string, flagName: string): Promise<boolean> {
|
|
94
|
+
const [row] = await this.db
|
|
95
|
+
.select()
|
|
96
|
+
.from(messageFlagTable)
|
|
97
|
+
.where(
|
|
98
|
+
and(
|
|
99
|
+
eq(messageFlagTable.messageId, messageId),
|
|
100
|
+
eq(messageFlagTable.flagName, flagName),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
return row !== undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async addFlag(messageId: string, flagName: string): Promise<MessageFlagItem> {
|
|
107
|
+
const existing = await this.db
|
|
108
|
+
.select()
|
|
109
|
+
.from(messageFlagTable)
|
|
110
|
+
.where(
|
|
111
|
+
and(
|
|
112
|
+
eq(messageFlagTable.messageId, messageId),
|
|
113
|
+
eq(messageFlagTable.flagName, flagName),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
if (existing[0]) return rowToMessageFlag(existing[0]);
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const messageFlagId = randomUUID();
|
|
119
|
+
const [row] = await this.db
|
|
120
|
+
.insert(messageFlagTable)
|
|
121
|
+
.values({
|
|
122
|
+
messageFlagId,
|
|
123
|
+
messageId,
|
|
124
|
+
flagName,
|
|
125
|
+
setAt: now,
|
|
126
|
+
createdAt: now,
|
|
127
|
+
updatedAt: now,
|
|
128
|
+
})
|
|
129
|
+
.returning();
|
|
130
|
+
if (!row) throw new NotFoundError(`MessageFlag addFlag failed`);
|
|
131
|
+
return rowToMessageFlag(row);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async removeFlag(messageId: string, flagName: string): Promise<void> {
|
|
135
|
+
await this.db
|
|
136
|
+
.delete(messageFlagTable)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(messageFlagTable.messageId, messageId),
|
|
140
|
+
eq(messageFlagTable.flagName, flagName),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async addFlags(messageId: string, flagNames: string[]): Promise<void> {
|
|
146
|
+
for (const flagName of flagNames) {
|
|
147
|
+
await this.addFlag(messageId, flagName);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async removeFlags(messageId: string, flagNames: string[]): Promise<void> {
|
|
152
|
+
if (flagNames.length === 0) return;
|
|
153
|
+
await this.db
|
|
154
|
+
.delete(messageFlagTable)
|
|
155
|
+
.where(
|
|
156
|
+
and(
|
|
157
|
+
eq(messageFlagTable.messageId, messageId),
|
|
158
|
+
inArray(messageFlagTable.flagName, flagNames),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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 { MessageLabelRepo } from "./message-label.js";
|
|
5
|
+
|
|
6
|
+
describe("MessageLabelRepo", () => {
|
|
7
|
+
let db: TestDb;
|
|
8
|
+
let close: () => Promise<void>;
|
|
9
|
+
let repo: MessageLabelRepo;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
({ db, close } = await createTestDb());
|
|
13
|
+
repo = new MessageLabelRepo(db as never);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
await close();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("apply is idempotent — the same (messageId, labelId) always derives the same row", async () => {
|
|
21
|
+
const accountConfigId = randomId();
|
|
22
|
+
const messageId = randomId();
|
|
23
|
+
const labelId = randomId();
|
|
24
|
+
|
|
25
|
+
const first = await repo.apply({ accountConfigId, messageId, labelId });
|
|
26
|
+
const second = await repo.apply({ accountConfigId, messageId, labelId });
|
|
27
|
+
|
|
28
|
+
assert.equal(first.messageLabelId, second.messageLabelId);
|
|
29
|
+
assert.equal(
|
|
30
|
+
first.messageLabelId,
|
|
31
|
+
MessageLabelRepo.deriveId(messageId, labelId),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const rows = await repo.listByMessageId(messageId);
|
|
35
|
+
assert.equal(rows.length, 1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("appliedByFilterId is absent for a manually-applied label", async () => {
|
|
39
|
+
const accountConfigId = randomId();
|
|
40
|
+
const messageId = randomId();
|
|
41
|
+
const labelId = randomId();
|
|
42
|
+
|
|
43
|
+
const row = await repo.apply({ accountConfigId, messageId, labelId });
|
|
44
|
+
|
|
45
|
+
assert.equal(row.appliedByFilterId, undefined);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("appliedByFilterId round-trips for a filter-applied label (RFC 034 Decision 3.3)", async () => {
|
|
49
|
+
const accountConfigId = randomId();
|
|
50
|
+
const messageId = randomId();
|
|
51
|
+
const labelId = randomId();
|
|
52
|
+
const appliedByFilterId = randomId();
|
|
53
|
+
|
|
54
|
+
const row = await repo.apply({
|
|
55
|
+
accountConfigId,
|
|
56
|
+
messageId,
|
|
57
|
+
labelId,
|
|
58
|
+
appliedByFilterId,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.equal(row.appliedByFilterId, appliedByFilterId);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("remove deletes the derived row", async () => {
|
|
65
|
+
const accountConfigId = randomId();
|
|
66
|
+
const messageId = randomId();
|
|
67
|
+
const labelId = randomId();
|
|
68
|
+
|
|
69
|
+
await repo.apply({ accountConfigId, messageId, labelId });
|
|
70
|
+
await repo.remove(messageId, labelId);
|
|
71
|
+
|
|
72
|
+
const labels = await repo.listByMessageId(messageId);
|
|
73
|
+
assert.equal(labels.length, 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("listByMessageId returns every label on a message", async () => {
|
|
77
|
+
const accountConfigId = randomId();
|
|
78
|
+
const messageId = randomId();
|
|
79
|
+
const labelIdA = randomId();
|
|
80
|
+
const labelIdB = randomId();
|
|
81
|
+
|
|
82
|
+
await repo.apply({ accountConfigId, messageId, labelId: labelIdA });
|
|
83
|
+
await repo.apply({ accountConfigId, messageId, labelId: labelIdB });
|
|
84
|
+
|
|
85
|
+
const labels = await repo.listByMessageId(messageId);
|
|
86
|
+
const ids = labels.map((l) => l.labelId).sort();
|
|
87
|
+
assert.deepEqual(ids, [labelIdA, labelIdB].sort());
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("listByLabelId scopes to the account and label", async () => {
|
|
91
|
+
const accountConfigId = randomId();
|
|
92
|
+
const labelId = randomId();
|
|
93
|
+
const messageIdA = randomId();
|
|
94
|
+
const messageIdB = randomId();
|
|
95
|
+
const foreignAccountConfigId = randomId();
|
|
96
|
+
const foreignMessageId = randomId();
|
|
97
|
+
|
|
98
|
+
await repo.apply({ accountConfigId, messageId: messageIdA, labelId });
|
|
99
|
+
await repo.apply({ accountConfigId, messageId: messageIdB, labelId });
|
|
100
|
+
await repo.apply({
|
|
101
|
+
accountConfigId: foreignAccountConfigId,
|
|
102
|
+
messageId: foreignMessageId,
|
|
103
|
+
labelId,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const rows = await repo.listByLabelId(accountConfigId, labelId);
|
|
107
|
+
const messageIds = rows.map((r) => r.messageId).sort();
|
|
108
|
+
assert.deepEqual(messageIds, [messageIdA, messageIdB].sort());
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateMessageLabelInput,
|
|
3
|
+
IMessageLabelRepository,
|
|
4
|
+
MessageLabelItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
7
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
8
|
+
import { deterministicBase36Id } from "../id.js";
|
|
9
|
+
import { messageLabelTable } from "../schema.js";
|
|
10
|
+
|
|
11
|
+
type DB = NodePgDatabase<Record<string, unknown>>;
|
|
12
|
+
|
|
13
|
+
function rowToMessageLabel(
|
|
14
|
+
row: typeof messageLabelTable.$inferSelect,
|
|
15
|
+
): MessageLabelItem {
|
|
16
|
+
return {
|
|
17
|
+
messageLabelId: row.messageLabelId,
|
|
18
|
+
messageId: row.messageId,
|
|
19
|
+
labelId: row.labelId,
|
|
20
|
+
accountConfigId: row.accountConfigId,
|
|
21
|
+
appliedByFilterId: row.appliedByFilterId ?? undefined,
|
|
22
|
+
createdAt: row.createdAt,
|
|
23
|
+
updatedAt: row.updatedAt,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MessageLabelRepo implements IMessageLabelRepository {
|
|
28
|
+
constructor(private db: DB) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deterministic primary key: base36 UUIDv5 of (messageId, labelId), the same
|
|
32
|
+
* derivation the electrodb adapter uses, so applying the same label twice is
|
|
33
|
+
* idempotent on either backend (RFC 030).
|
|
34
|
+
*/
|
|
35
|
+
static deriveId(messageId: string, labelId: string): string {
|
|
36
|
+
return deterministicBase36Id(`messageLabel:${messageId}:${labelId}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async apply(input: CreateMessageLabelInput): Promise<MessageLabelItem> {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const messageLabelId = MessageLabelRepo.deriveId(
|
|
42
|
+
input.messageId,
|
|
43
|
+
input.labelId,
|
|
44
|
+
);
|
|
45
|
+
const appliedByFilterId = input.appliedByFilterId ?? null;
|
|
46
|
+
const [row] = await this.db
|
|
47
|
+
.insert(messageLabelTable)
|
|
48
|
+
.values({
|
|
49
|
+
messageLabelId,
|
|
50
|
+
messageId: input.messageId,
|
|
51
|
+
labelId: input.labelId,
|
|
52
|
+
accountConfigId: input.accountConfigId,
|
|
53
|
+
appliedByFilterId,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
})
|
|
57
|
+
.onConflictDoUpdate({
|
|
58
|
+
target: messageLabelTable.messageLabelId,
|
|
59
|
+
set: { appliedByFilterId, updatedAt: now },
|
|
60
|
+
})
|
|
61
|
+
.returning();
|
|
62
|
+
return rowToMessageLabel(row);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async remove(messageId: string, labelId: string): Promise<void> {
|
|
66
|
+
const messageLabelId = MessageLabelRepo.deriveId(messageId, labelId);
|
|
67
|
+
await this.db
|
|
68
|
+
.delete(messageLabelTable)
|
|
69
|
+
.where(eq(messageLabelTable.messageLabelId, messageLabelId));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async listByMessageId(messageId: string): Promise<MessageLabelItem[]> {
|
|
73
|
+
const rows = await this.db
|
|
74
|
+
.select()
|
|
75
|
+
.from(messageLabelTable)
|
|
76
|
+
.where(eq(messageLabelTable.messageId, messageId));
|
|
77
|
+
return rows.map(rowToMessageLabel);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async listByLabelId(
|
|
81
|
+
accountConfigId: string,
|
|
82
|
+
labelId: string,
|
|
83
|
+
): Promise<MessageLabelItem[]> {
|
|
84
|
+
const rows = await this.db
|
|
85
|
+
.select()
|
|
86
|
+
.from(messageLabelTable)
|
|
87
|
+
.where(
|
|
88
|
+
and(
|
|
89
|
+
eq(messageLabelTable.accountConfigId, accountConfigId),
|
|
90
|
+
eq(messageLabelTable.labelId, labelId),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
.orderBy(desc(messageLabelTable.createdAt));
|
|
94
|
+
return rows.map(rowToMessageLabel);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import {
|
|
5
|
+
envelopeId as deriveEnvelopeId,
|
|
6
|
+
rootBodyPartId as deriveRootBodyPartId,
|
|
7
|
+
} from "../id.js";
|
|
8
|
+
import {
|
|
9
|
+
type MessageDataSchema,
|
|
10
|
+
messageDataSchema,
|
|
11
|
+
outboxTable,
|
|
12
|
+
} from "../schema/message-data.js";
|
|
13
|
+
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
14
|
+
import { DrizzleMessageRepository } from "./message.js";
|
|
15
|
+
|
|
16
|
+
// Runs the ported message repo against a real better-sqlite3 database
|
|
17
|
+
// (RFC 036 D1). Exercises the SQLite-specific seams: the SAVEPOINT-bracketed
|
|
18
|
+
// transaction, the sqlite outbox table (text-json payload), the unique-
|
|
19
|
+
// violation → CreateFailedConflictError mapping, and the JSON/boolean columns.
|
|
20
|
+
|
|
21
|
+
describe("DrizzleMessageRepository (sqlite)", () => {
|
|
22
|
+
let db: SqliteTestDb<MessageDataSchema>;
|
|
23
|
+
let close: () => Promise<void>;
|
|
24
|
+
let repo: DrizzleMessageRepository;
|
|
25
|
+
|
|
26
|
+
const MESSAGE_ID = "00000000-0000-0000-2222-000000000001";
|
|
27
|
+
const MAILBOX_ID = "00000000-0000-0000-2222-000000000002";
|
|
28
|
+
const NOW = 1700000000000;
|
|
29
|
+
|
|
30
|
+
const BASE_INPUT = {
|
|
31
|
+
messageId: MESSAGE_ID,
|
|
32
|
+
mailboxId: MAILBOX_ID,
|
|
33
|
+
uid: 42,
|
|
34
|
+
sequenceNumber: 1,
|
|
35
|
+
rfc822Size: 1024,
|
|
36
|
+
internalDate: NOW,
|
|
37
|
+
messageIdHeader: "<test@example.com>",
|
|
38
|
+
envelopeId: deriveEnvelopeId(MESSAGE_ID),
|
|
39
|
+
rootBodyPartId: deriveRootBodyPartId(MESSAGE_ID),
|
|
40
|
+
status: "active" as const,
|
|
41
|
+
syncStatus: "synced" as const,
|
|
42
|
+
hasListUnsubscribe: true,
|
|
43
|
+
authenticity: { spf: "pass" } as unknown as never,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
before(async () => {
|
|
47
|
+
({ db, close } = await createSqliteTestDb(messageDataSchema));
|
|
48
|
+
repo = new DrizzleMessageRepository(db);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
after(async () => {
|
|
52
|
+
await close();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("create returns a MessageItem and writes one outbox row atomically", async () => {
|
|
56
|
+
const item = await repo.create(BASE_INPUT);
|
|
57
|
+
assert.equal(item.messageId, MESSAGE_ID);
|
|
58
|
+
assert.equal(item.status, "active");
|
|
59
|
+
assert.equal(item.hasListUnsubscribe, true);
|
|
60
|
+
|
|
61
|
+
const rows = await db
|
|
62
|
+
.select()
|
|
63
|
+
.from(outboxTable)
|
|
64
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
65
|
+
assert.equal(rows.length, 1);
|
|
66
|
+
assert.equal(rows[0].event, "message.created");
|
|
67
|
+
assert.deepEqual(rows[0].payload, { messageId: MESSAGE_ID });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("boolean and json columns round-trip", async () => {
|
|
71
|
+
const item = await repo.get(MESSAGE_ID);
|
|
72
|
+
assert.equal(item.hasListUnsubscribe, true);
|
|
73
|
+
assert.equal(item.movedByRemit, false);
|
|
74
|
+
assert.deepEqual(item.authenticity, { spf: "pass" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("duplicate messageId throws CreateFailedConflictError and rolls back the outbox row", async () => {
|
|
78
|
+
const before = await db
|
|
79
|
+
.select()
|
|
80
|
+
.from(outboxTable)
|
|
81
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
82
|
+
|
|
83
|
+
await assert.rejects(
|
|
84
|
+
() => repo.create(BASE_INPUT),
|
|
85
|
+
(err: Error) => err.name === "CreateFailedConflictError",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const afterRows = await db
|
|
89
|
+
.select()
|
|
90
|
+
.from(outboxTable)
|
|
91
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
92
|
+
assert.equal(
|
|
93
|
+
afterRows.length,
|
|
94
|
+
before.length,
|
|
95
|
+
"rolled-back create must not append an outbox row",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("upsertWithStatus reports created=false for an existing row", async () => {
|
|
100
|
+
const result = await repo.upsertWithStatus(BASE_INPUT);
|
|
101
|
+
assert.equal(result.created, false);
|
|
102
|
+
assert.equal(result.item.messageId, MESSAGE_ID);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("delete removes the message and appends a removal outbox row", async () => {
|
|
106
|
+
await repo.delete(MESSAGE_ID);
|
|
107
|
+
await assert.rejects(() => repo.get(MESSAGE_ID));
|
|
108
|
+
|
|
109
|
+
const rows = await db
|
|
110
|
+
.select()
|
|
111
|
+
.from(outboxTable)
|
|
112
|
+
.where(eq(outboxTable.messageId, MESSAGE_ID));
|
|
113
|
+
assert.ok(
|
|
114
|
+
rows.some((r) => r.event === "message.removed"),
|
|
115
|
+
"delete must append a message.removed outbox row",
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|