@remit/drizzle-service 0.0.38 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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 {
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import * as entities from "@remit/drizzle-sqlite-schema";
2
+
3
+ export const outboxAttachmentTable = entities.outboxAttachments;
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";