@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.
Files changed (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. 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
+ });