@remit/drizzle-service 0.0.37 → 0.0.39
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/index.ts +1 -0
- package/src/repos/i4-message-flag-push.test.ts +20 -0
- package/src/repos/i4-message-flag-push.ts +9 -3
- package/src/repos/i4-outbox-attachment.test.ts +176 -0
- package/src/repos/i4-outbox-attachment.ts +213 -0
- package/src/repos/message.ts +29 -0
- package/src/schema/i4-outbox-attachment.ts +3 -0
- package/src/schema.ts +1 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export {
|
|
|
31
31
|
type PutMessagePlacementMoveInput,
|
|
32
32
|
} from "./repos/i4-message-placement-move.js";
|
|
33
33
|
export * from "./repos/i4-organize-job-request.js";
|
|
34
|
+
export { OutboxAttachmentRepo } from "./repos/i4-outbox-attachment.js";
|
|
34
35
|
export * from "./repos/i4-outbox-message.js";
|
|
35
36
|
export { LabelRepo } from "./repos/label.js";
|
|
36
37
|
export {
|
|
@@ -122,6 +122,26 @@ describe("MessageFlagPushRepo (relational counterpart to MessageFlagPushService)
|
|
|
122
122
|
assert.equal(found?.state, "pending");
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test("put resets createdAt too — a stale createdAt would misread as a stuck deferred marker later", async () => {
|
|
126
|
+
const input = seedInput();
|
|
127
|
+
const first = await repo.put(input);
|
|
128
|
+
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
130
|
+
|
|
131
|
+
const second = await repo.put(
|
|
132
|
+
seedInput({
|
|
133
|
+
messageId: input.messageId,
|
|
134
|
+
flagName: input.flagName,
|
|
135
|
+
operation: "remove",
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
assert.ok(
|
|
140
|
+
second.createdAt > first.createdAt,
|
|
141
|
+
"a replacing put must start a fresh lifecycle, not inherit the row it replaces",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
125
145
|
test("put is idempotent — a later flip of the SAME field replaces the marker", async () => {
|
|
126
146
|
const messageId = randomId();
|
|
127
147
|
const flagName = "\\Seen";
|
|
@@ -64,9 +64,14 @@ export class MessageFlagPushRepo {
|
|
|
64
64
|
input: PutMessageFlagPushInput,
|
|
65
65
|
): Promise<MessageFlagPushItem> => {
|
|
66
66
|
const now = Date.now();
|
|
67
|
-
// A fresh put ALWAYS resets state to `pending`
|
|
68
|
-
//
|
|
69
|
-
// state the row it replaces was in.
|
|
67
|
+
// A fresh put ALWAYS resets state to `pending` AND createdAt to now — a
|
|
68
|
+
// new flip decision starts a genuinely new lifecycle regardless of
|
|
69
|
+
// whatever state (or age) the row it replaces was in. The conflict path
|
|
70
|
+
// must reset createdAt explicitly: onConflictDoUpdate only touches the
|
|
71
|
+
// columns named in `set`, so an omission here would let a replacing put
|
|
72
|
+
// silently inherit the original row's createdAt (its age then misreads
|
|
73
|
+
// as a stuck deferred marker elsewhere — see flag-push.ts's defer-max
|
|
74
|
+
// check, which measures a marker's age off exactly this field).
|
|
70
75
|
const [row] = await this.db
|
|
71
76
|
.insert(messageFlagPushTable)
|
|
72
77
|
.values({
|
|
@@ -83,6 +88,7 @@ export class MessageFlagPushRepo {
|
|
|
83
88
|
mailboxId: input.mailboxId,
|
|
84
89
|
operation: input.operation,
|
|
85
90
|
state: DEFAULT_STATE,
|
|
91
|
+
createdAt: now,
|
|
86
92
|
updatedAt: now,
|
|
87
93
|
},
|
|
88
94
|
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { createTestDb, randomId } from "../test-db.js";
|
|
4
|
+
import { OutboxAttachmentRepo } from "./i4-outbox-attachment.js";
|
|
5
|
+
|
|
6
|
+
const CAP = { maxTotalBytes: 1000, maxCount: 3, nowSeconds: 1_000_000 };
|
|
7
|
+
|
|
8
|
+
let harness: Awaited<ReturnType<typeof createTestDb>>;
|
|
9
|
+
let repo: OutboxAttachmentRepo;
|
|
10
|
+
|
|
11
|
+
const input = (
|
|
12
|
+
accountConfigId: string,
|
|
13
|
+
outboxMessageId: string,
|
|
14
|
+
sizeBytes: number,
|
|
15
|
+
reservationExpiresAt = CAP.nowSeconds + 900,
|
|
16
|
+
) => ({
|
|
17
|
+
outboxAttachmentId: randomId(),
|
|
18
|
+
outboxMessageId,
|
|
19
|
+
accountId: "acc-1",
|
|
20
|
+
accountConfigId,
|
|
21
|
+
filename: "a.bin",
|
|
22
|
+
contentType: "application/octet-stream",
|
|
23
|
+
sizeBytes,
|
|
24
|
+
storageKey: `accounts/${accountConfigId}/acc-1/outbox/${outboxMessageId}/attachments/x`,
|
|
25
|
+
reservationExpiresAt,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
before(async () => {
|
|
29
|
+
harness = await createTestDb();
|
|
30
|
+
repo = new OutboxAttachmentRepo(harness.db);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
after(async () => {
|
|
34
|
+
await harness.close();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("OutboxAttachmentRepo.reserve", () => {
|
|
38
|
+
test("counts what a draft already holds and refuses what will not fit", async () => {
|
|
39
|
+
const cfg = randomId();
|
|
40
|
+
const draft = randomId();
|
|
41
|
+
|
|
42
|
+
assert.strictEqual(
|
|
43
|
+
(await repo.reserve(input(cfg, draft, 800), CAP)).outcome,
|
|
44
|
+
"Reserved",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const over = await repo.reserve(input(cfg, draft, 300), CAP);
|
|
48
|
+
assert.strictEqual(over.outcome, "OverByteCap");
|
|
49
|
+
assert.strictEqual(over.outcome === "OverByteCap" && over.usedBytes, 800);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("refuses past the file-count ceiling", async () => {
|
|
53
|
+
const cfg = randomId();
|
|
54
|
+
const draft = randomId();
|
|
55
|
+
for (let index = 0; index < CAP.maxCount; index += 1) {
|
|
56
|
+
assert.strictEqual(
|
|
57
|
+
(await repo.reserve(input(cfg, draft, 1), CAP)).outcome,
|
|
58
|
+
"Reserved",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const over = await repo.reserve(input(cfg, draft, 1), CAP);
|
|
63
|
+
assert.strictEqual(over.outcome, "OverCountCap");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("holds the cap when reservations arrive together", async () => {
|
|
67
|
+
// Counting and inserting are one transaction, so concurrent callers are
|
|
68
|
+
// ordered by the database rather than each measuring a draft none of them
|
|
69
|
+
// has written to. This is the whole of the cap.
|
|
70
|
+
const cfg = randomId();
|
|
71
|
+
const draft = randomId();
|
|
72
|
+
|
|
73
|
+
const results = await Promise.all(
|
|
74
|
+
Array.from({ length: 6 }, () =>
|
|
75
|
+
repo.reserve(input(cfg, draft, 300), CAP),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const reserved = results.filter((r) => r.outcome === "Reserved");
|
|
80
|
+
assert.strictEqual(reserved.length, 3);
|
|
81
|
+
const rows = await repo.listByOutboxMessage(cfg, draft);
|
|
82
|
+
assert.strictEqual(rows.length, 3);
|
|
83
|
+
assert.strictEqual(
|
|
84
|
+
rows.reduce((total, row) => total + row.sizeBytes, 0),
|
|
85
|
+
900,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a lapsed reservation stops holding room", async () => {
|
|
90
|
+
const cfg = randomId();
|
|
91
|
+
const draft = randomId();
|
|
92
|
+
await repo.reserve(input(cfg, draft, 900, CAP.nowSeconds - 1), CAP);
|
|
93
|
+
|
|
94
|
+
assert.strictEqual(
|
|
95
|
+
(await repo.reserve(input(cfg, draft, 900), CAP)).outcome,
|
|
96
|
+
"Reserved",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("another tenant's rows are not counted, and cannot be read", async () => {
|
|
101
|
+
const draft = randomId();
|
|
102
|
+
const mine = randomId();
|
|
103
|
+
const theirs = randomId();
|
|
104
|
+
await repo.reserve(input(theirs, draft, 900), CAP);
|
|
105
|
+
|
|
106
|
+
assert.strictEqual(
|
|
107
|
+
(await repo.reserve(input(mine, draft, 900), CAP)).outcome,
|
|
108
|
+
"Reserved",
|
|
109
|
+
);
|
|
110
|
+
assert.deepStrictEqual(await repo.listByOutboxMessage(mine, draft), [
|
|
111
|
+
(await repo.listByOutboxMessage(mine, draft))[0],
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("OutboxAttachmentRepo.markStored", () => {
|
|
117
|
+
test("moves a Pending row to Stored at the size storage holds", async () => {
|
|
118
|
+
const cfg = randomId();
|
|
119
|
+
const draft = randomId();
|
|
120
|
+
const reserved = await repo.reserve(input(cfg, draft, 100), CAP);
|
|
121
|
+
assert.strictEqual(reserved.outcome, "Reserved");
|
|
122
|
+
if (reserved.outcome !== "Reserved") return;
|
|
123
|
+
|
|
124
|
+
const stored = await repo.markStored(
|
|
125
|
+
cfg,
|
|
126
|
+
reserved.item.outboxAttachmentId,
|
|
127
|
+
100,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
assert.strictEqual(stored?.state, "Stored");
|
|
131
|
+
// A Stored row holds room forever, so its expiry has nothing left to say.
|
|
132
|
+
assert.strictEqual(stored?.reservationExpiresAt, 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("answers null the second time, so a retry cannot double-confirm", async () => {
|
|
136
|
+
const cfg = randomId();
|
|
137
|
+
const draft = randomId();
|
|
138
|
+
const reserved = await repo.reserve(input(cfg, draft, 100), CAP);
|
|
139
|
+
assert.strictEqual(reserved.outcome, "Reserved");
|
|
140
|
+
if (reserved.outcome !== "Reserved") return;
|
|
141
|
+
|
|
142
|
+
await repo.markStored(cfg, reserved.item.outboxAttachmentId, 100);
|
|
143
|
+
assert.strictEqual(
|
|
144
|
+
await repo.markStored(cfg, reserved.item.outboxAttachmentId, 100),
|
|
145
|
+
null,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("refuses to touch another tenant's row", async () => {
|
|
150
|
+
const cfg = randomId();
|
|
151
|
+
const draft = randomId();
|
|
152
|
+
const reserved = await repo.reserve(input(cfg, draft, 100), CAP);
|
|
153
|
+
assert.strictEqual(reserved.outcome, "Reserved");
|
|
154
|
+
if (reserved.outcome !== "Reserved") return;
|
|
155
|
+
|
|
156
|
+
assert.strictEqual(
|
|
157
|
+
await repo.markStored(randomId(), reserved.item.outboxAttachmentId, 100),
|
|
158
|
+
null,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("OutboxAttachmentRepo deletion", () => {
|
|
164
|
+
test("deleteByOutboxMessage empties one draft and leaves the rest", async () => {
|
|
165
|
+
const cfg = randomId();
|
|
166
|
+
const kept = randomId();
|
|
167
|
+
const gone = randomId();
|
|
168
|
+
await repo.reserve(input(cfg, kept, 10), CAP);
|
|
169
|
+
await repo.reserve(input(cfg, gone, 10), CAP);
|
|
170
|
+
|
|
171
|
+
await repo.deleteByOutboxMessage(cfg, gone);
|
|
172
|
+
|
|
173
|
+
assert.strictEqual((await repo.listByOutboxMessage(cfg, gone)).length, 0);
|
|
174
|
+
assert.strictEqual((await repo.listByOutboxMessage(cfg, kept)).length, 1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateOutboxAttachmentInput,
|
|
3
|
+
IOutboxAttachmentRepository,
|
|
4
|
+
OutboxAttachmentCap,
|
|
5
|
+
OutboxAttachmentItem,
|
|
6
|
+
ReserveOutboxAttachmentResult,
|
|
7
|
+
} from "@remit/data-ports";
|
|
8
|
+
import { holdsRoom } from "@remit/data-ports";
|
|
9
|
+
import { and, eq, inArray, lt } from "drizzle-orm";
|
|
10
|
+
import type { Db } from "../db.js";
|
|
11
|
+
import { NotFoundError } from "../error.js";
|
|
12
|
+
import { outboxAttachmentTable } from "../schema/i4-outbox-attachment.js";
|
|
13
|
+
import { runInTransaction } from "../tx.js";
|
|
14
|
+
|
|
15
|
+
type DB = Db<Record<string, unknown>>;
|
|
16
|
+
|
|
17
|
+
function rowToOutboxAttachment(
|
|
18
|
+
row: typeof outboxAttachmentTable.$inferSelect,
|
|
19
|
+
): OutboxAttachmentItem {
|
|
20
|
+
return {
|
|
21
|
+
outboxAttachmentId: row.outboxAttachmentId,
|
|
22
|
+
outboxMessageId: row.outboxMessageId,
|
|
23
|
+
accountId: row.accountId,
|
|
24
|
+
accountConfigId: row.accountConfigId,
|
|
25
|
+
filename: row.filename,
|
|
26
|
+
contentType: row.contentType,
|
|
27
|
+
sizeBytes: row.sizeBytes,
|
|
28
|
+
state: row.state as OutboxAttachmentItem["state"],
|
|
29
|
+
storageKey: row.storageKey,
|
|
30
|
+
reservationExpiresAt: row.reservationExpiresAt,
|
|
31
|
+
createdAt: row.createdAt,
|
|
32
|
+
updatedAt: row.updatedAt,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class OutboxAttachmentRepo implements IOutboxAttachmentRepository {
|
|
37
|
+
constructor(private db: DB) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The per-message cap: the count and the insert are one transaction, on the
|
|
41
|
+
* transaction's own handle, so a concurrent reservation cannot land between
|
|
42
|
+
* them. See the port's contract for the invariant this owes its callers and
|
|
43
|
+
* what an adapter on another engine has to do to keep it.
|
|
44
|
+
*/
|
|
45
|
+
async reserve(
|
|
46
|
+
input: CreateOutboxAttachmentInput,
|
|
47
|
+
cap: OutboxAttachmentCap,
|
|
48
|
+
): Promise<ReserveOutboxAttachmentResult> {
|
|
49
|
+
return runInTransaction(this.db, async (tx) => {
|
|
50
|
+
const existing = await tx
|
|
51
|
+
.select()
|
|
52
|
+
.from(outboxAttachmentTable)
|
|
53
|
+
.where(
|
|
54
|
+
and(
|
|
55
|
+
eq(outboxAttachmentTable.accountConfigId, input.accountConfigId),
|
|
56
|
+
eq(outboxAttachmentTable.outboxMessageId, input.outboxMessageId),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const live = existing
|
|
61
|
+
.map(rowToOutboxAttachment)
|
|
62
|
+
.filter((item) => holdsRoom(item, cap.nowSeconds));
|
|
63
|
+
const usedBytes = live.reduce((total, item) => total + item.sizeBytes, 0);
|
|
64
|
+
|
|
65
|
+
if (live.length >= cap.maxCount) {
|
|
66
|
+
return { outcome: "OverCountCap", usedBytes };
|
|
67
|
+
}
|
|
68
|
+
if (usedBytes + input.sizeBytes > cap.maxTotalBytes) {
|
|
69
|
+
return { outcome: "OverByteCap", usedBytes };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const [row] = await tx
|
|
74
|
+
.insert(outboxAttachmentTable)
|
|
75
|
+
.values({
|
|
76
|
+
outboxAttachmentId: input.outboxAttachmentId,
|
|
77
|
+
outboxMessageId: input.outboxMessageId,
|
|
78
|
+
accountId: input.accountId,
|
|
79
|
+
accountConfigId: input.accountConfigId,
|
|
80
|
+
filename: input.filename,
|
|
81
|
+
contentType: input.contentType,
|
|
82
|
+
sizeBytes: input.sizeBytes,
|
|
83
|
+
state: "Pending",
|
|
84
|
+
storageKey: input.storageKey,
|
|
85
|
+
reservationExpiresAt: input.reservationExpiresAt,
|
|
86
|
+
createdAt: now,
|
|
87
|
+
updatedAt: now,
|
|
88
|
+
})
|
|
89
|
+
.returning();
|
|
90
|
+
|
|
91
|
+
return { outcome: "Reserved", item: rowToOutboxAttachment(row) };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async get(
|
|
96
|
+
accountConfigId: string,
|
|
97
|
+
outboxAttachmentId: string,
|
|
98
|
+
): Promise<OutboxAttachmentItem> {
|
|
99
|
+
const [row] = await this.db
|
|
100
|
+
.select()
|
|
101
|
+
.from(outboxAttachmentTable)
|
|
102
|
+
.where(
|
|
103
|
+
and(
|
|
104
|
+
eq(outboxAttachmentTable.accountConfigId, accountConfigId),
|
|
105
|
+
eq(outboxAttachmentTable.outboxAttachmentId, outboxAttachmentId),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
.limit(1);
|
|
109
|
+
if (!row) {
|
|
110
|
+
throw new NotFoundError(`No outbox attachment ${outboxAttachmentId}`);
|
|
111
|
+
}
|
|
112
|
+
return rowToOutboxAttachment(row);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async listByOutboxMessage(
|
|
116
|
+
accountConfigId: string,
|
|
117
|
+
outboxMessageId: string,
|
|
118
|
+
): Promise<OutboxAttachmentItem[]> {
|
|
119
|
+
const rows = await this.db
|
|
120
|
+
.select()
|
|
121
|
+
.from(outboxAttachmentTable)
|
|
122
|
+
.where(
|
|
123
|
+
and(
|
|
124
|
+
eq(outboxAttachmentTable.accountConfigId, accountConfigId),
|
|
125
|
+
eq(outboxAttachmentTable.outboxMessageId, outboxMessageId),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
return rows.map(rowToOutboxAttachment);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Only a Pending row that has not lapsed may become Stored, and the update
|
|
133
|
+
* says so in its WHERE rather than in a preceding read — two completions for
|
|
134
|
+
* the same upload cannot both believe they were the one that confirmed it.
|
|
135
|
+
*/
|
|
136
|
+
async markStored(
|
|
137
|
+
accountConfigId: string,
|
|
138
|
+
outboxAttachmentId: string,
|
|
139
|
+
sizeBytes: number,
|
|
140
|
+
): Promise<OutboxAttachmentItem | null> {
|
|
141
|
+
const [row] = await this.db
|
|
142
|
+
.update(outboxAttachmentTable)
|
|
143
|
+
.set({
|
|
144
|
+
state: "Stored",
|
|
145
|
+
sizeBytes,
|
|
146
|
+
// Stored rows hold room forever, so the expiry has nothing left to
|
|
147
|
+
// say. Zero is that, explicitly.
|
|
148
|
+
reservationExpiresAt: 0,
|
|
149
|
+
updatedAt: Date.now(),
|
|
150
|
+
})
|
|
151
|
+
.where(
|
|
152
|
+
and(
|
|
153
|
+
eq(outboxAttachmentTable.accountConfigId, accountConfigId),
|
|
154
|
+
eq(outboxAttachmentTable.outboxAttachmentId, outboxAttachmentId),
|
|
155
|
+
eq(outboxAttachmentTable.state, "Pending"),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
.returning();
|
|
159
|
+
|
|
160
|
+
return row ? rowToOutboxAttachment(row) : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async deleteLapsedReservations(
|
|
164
|
+
accountConfigId: string,
|
|
165
|
+
outboxMessageId: string,
|
|
166
|
+
nowSeconds: number,
|
|
167
|
+
): Promise<string[]> {
|
|
168
|
+
const rows = await this.db
|
|
169
|
+
.delete(outboxAttachmentTable)
|
|
170
|
+
.where(
|
|
171
|
+
and(
|
|
172
|
+
eq(outboxAttachmentTable.accountConfigId, accountConfigId),
|
|
173
|
+
eq(outboxAttachmentTable.outboxMessageId, outboxMessageId),
|
|
174
|
+
eq(outboxAttachmentTable.state, "Pending"),
|
|
175
|
+
lt(outboxAttachmentTable.reservationExpiresAt, nowSeconds),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
.returning();
|
|
179
|
+
return rows.map((row) => row.outboxAttachmentId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async deleteMany(
|
|
183
|
+
accountConfigId: string,
|
|
184
|
+
outboxAttachmentIds: string[],
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
if (outboxAttachmentIds.length === 0) return;
|
|
187
|
+
await this.db
|
|
188
|
+
.delete(outboxAttachmentTable)
|
|
189
|
+
.where(
|
|
190
|
+
and(
|
|
191
|
+
eq(outboxAttachmentTable.accountConfigId, accountConfigId),
|
|
192
|
+
inArray(
|
|
193
|
+
outboxAttachmentTable.outboxAttachmentId,
|
|
194
|
+
outboxAttachmentIds,
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async deleteByOutboxMessage(
|
|
201
|
+
accountConfigId: string,
|
|
202
|
+
outboxMessageId: string,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await this.db
|
|
205
|
+
.delete(outboxAttachmentTable)
|
|
206
|
+
.where(
|
|
207
|
+
and(
|
|
208
|
+
eq(outboxAttachmentTable.accountConfigId, accountConfigId),
|
|
209
|
+
eq(outboxAttachmentTable.outboxMessageId, outboxMessageId),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/repos/message.ts
CHANGED
|
@@ -94,6 +94,9 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
94
94
|
...(row.placementDecidedAt !== null
|
|
95
95
|
? { placementDecidedAt: row.placementDecidedAt }
|
|
96
96
|
: {}),
|
|
97
|
+
...(row.spamReport !== null
|
|
98
|
+
? { spamReport: row.spamReport as MessageItem["spamReport"] }
|
|
99
|
+
: {}),
|
|
97
100
|
};
|
|
98
101
|
}
|
|
99
102
|
|
|
@@ -193,6 +196,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
193
196
|
placementVerdict: input.placementVerdict ?? null,
|
|
194
197
|
filterMove: input.filterMove ?? null,
|
|
195
198
|
placementDecidedAt: input.placementDecidedAt ?? null,
|
|
199
|
+
spamReport: input.spamReport ?? null,
|
|
196
200
|
createdAt: now,
|
|
197
201
|
updatedAt: now,
|
|
198
202
|
};
|
|
@@ -388,6 +392,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
388
392
|
...(input.messageIdHeader !== undefined
|
|
389
393
|
? { messageIdHeader: input.messageIdHeader }
|
|
390
394
|
: {}),
|
|
395
|
+
...(input.spamReport !== undefined
|
|
396
|
+
? { spamReport: input.spamReport }
|
|
397
|
+
: {}),
|
|
391
398
|
updatedAt: now,
|
|
392
399
|
};
|
|
393
400
|
|
|
@@ -432,6 +439,28 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
432
439
|
return this.get(messageId);
|
|
433
440
|
}
|
|
434
441
|
|
|
442
|
+
async clearSpamReport(
|
|
443
|
+
messageId: string,
|
|
444
|
+
): ReturnType<IMessageRepository["clearSpamReport"]> {
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
await this.db
|
|
447
|
+
.update(messageTable)
|
|
448
|
+
.set({ spamReport: null, updatedAt: now })
|
|
449
|
+
.where(eq(messageTable.messageId, messageId));
|
|
450
|
+
return this.get(messageId);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async clearOriginalMailboxId(
|
|
454
|
+
messageId: string,
|
|
455
|
+
): ReturnType<IMessageRepository["clearOriginalMailboxId"]> {
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
await this.db
|
|
458
|
+
.update(messageTable)
|
|
459
|
+
.set({ originalMailboxId: null, originalUid: null, updatedAt: now })
|
|
460
|
+
.where(eq(messageTable.messageId, messageId));
|
|
461
|
+
return this.get(messageId);
|
|
462
|
+
}
|
|
463
|
+
|
|
435
464
|
async delete(messageId: string): Promise<void> {
|
|
436
465
|
await this.deleteMany([messageId]);
|
|
437
466
|
}
|
package/src/schema.ts
CHANGED
|
@@ -23,6 +23,7 @@ export * from "./schema/i4-mailbox-lock.js";
|
|
|
23
23
|
export * from "./schema/i4-message-flag-push.js";
|
|
24
24
|
export * from "./schema/i4-message-placement-move.js";
|
|
25
25
|
export * from "./schema/i4-organize-job-request.js";
|
|
26
|
+
export * from "./schema/i4-outbox-attachment.js";
|
|
26
27
|
export * from "./schema/i4-outbox-message.js";
|
|
27
28
|
export * from "./schema/message-data.js";
|
|
28
29
|
export * from "./schema/quarantine.js";
|