@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,131 @@
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 { FilterAnchorRepo } from "./filter-anchor.js";
5
+
6
+ describe("FilterAnchorRepo", () => {
7
+ let db: TestDb;
8
+ let close: () => Promise<void>;
9
+ let repo: FilterAnchorRepo;
10
+
11
+ before(async () => {
12
+ ({ db, close } = await createTestDb());
13
+ repo = new FilterAnchorRepo(db as never);
14
+ });
15
+
16
+ after(async () => {
17
+ await close();
18
+ });
19
+
20
+ test("put creates the row and round-trips the anchor vector", async () => {
21
+ const accountConfigId = randomId();
22
+ const filterId = randomId();
23
+ const anchorMessageId = randomId();
24
+ const anchorEmbedding = [0.1, -0.2, 0.3, 0];
25
+
26
+ const anchor = await repo.put({
27
+ accountConfigId,
28
+ filterId,
29
+ anchorEmbedding,
30
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
31
+ anchorSourceText: "Your booking is confirmed for...",
32
+ anchorMessageId,
33
+ });
34
+
35
+ assert.deepEqual(anchor.anchorEmbedding, anchorEmbedding);
36
+ assert.equal(anchor.anchorMessageId, anchorMessageId);
37
+ });
38
+
39
+ test("get returns null when no anchor exists for the filter", async () => {
40
+ const anchor = await repo.get(randomId(), randomId());
41
+ assert.equal(anchor, null);
42
+ });
43
+
44
+ test("put upserts — a re-embed migration overwrites the existing row", async () => {
45
+ const accountConfigId = randomId();
46
+ const filterId = randomId();
47
+ const anchorMessageId = randomId();
48
+
49
+ await repo.put({
50
+ accountConfigId,
51
+ filterId,
52
+ anchorEmbedding: [1, 2, 3],
53
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
54
+ anchorSourceText: "original",
55
+ anchorMessageId,
56
+ });
57
+
58
+ const migrated = await repo.put({
59
+ accountConfigId,
60
+ filterId,
61
+ anchorEmbedding: [4, 5, 6],
62
+ anchorEmbeddingId: "amazon.titan-embed-text-v3:0@1536",
63
+ anchorSourceText: "original",
64
+ anchorMessageId,
65
+ });
66
+
67
+ assert.deepEqual(migrated.anchorEmbedding, [4, 5, 6]);
68
+ assert.equal(
69
+ migrated.anchorEmbeddingId,
70
+ "amazon.titan-embed-text-v3:0@1536",
71
+ );
72
+
73
+ const reread = await repo.get(accountConfigId, filterId);
74
+ assert.deepEqual(reread?.anchorEmbedding, [4, 5, 6]);
75
+ });
76
+
77
+ test("listByAccountConfig returns every anchor for the account config", async () => {
78
+ const accountConfigId = randomId();
79
+ const filterIdA = randomId();
80
+ const filterIdB = randomId();
81
+ const otherConfigId = randomId();
82
+ const otherFilterId = randomId();
83
+
84
+ await repo.put({
85
+ accountConfigId,
86
+ filterId: filterIdA,
87
+ anchorEmbedding: [1, 2, 3],
88
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
89
+ anchorSourceText: "a",
90
+ anchorMessageId: randomId(),
91
+ });
92
+ await repo.put({
93
+ accountConfigId,
94
+ filterId: filterIdB,
95
+ anchorEmbedding: [4, 5, 6],
96
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
97
+ anchorSourceText: "b",
98
+ anchorMessageId: randomId(),
99
+ });
100
+ await repo.put({
101
+ accountConfigId: otherConfigId,
102
+ filterId: otherFilterId,
103
+ anchorEmbedding: [7, 8, 9],
104
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
105
+ anchorSourceText: "other",
106
+ anchorMessageId: randomId(),
107
+ });
108
+
109
+ const anchors = await repo.listByAccountConfig(accountConfigId);
110
+ const filterIds = anchors.map((a) => a.filterId).sort();
111
+ assert.deepEqual(filterIds, [filterIdA, filterIdB].sort());
112
+ });
113
+
114
+ test("delete removes the row", async () => {
115
+ const accountConfigId = randomId();
116
+ const filterId = randomId();
117
+
118
+ await repo.put({
119
+ accountConfigId,
120
+ filterId,
121
+ anchorEmbedding: [1],
122
+ anchorEmbeddingId: "amazon.titan-embed-text-v2:0@1024",
123
+ anchorSourceText: "text",
124
+ anchorMessageId: randomId(),
125
+ });
126
+
127
+ await repo.delete(accountConfigId, filterId);
128
+
129
+ assert.equal(await repo.get(accountConfigId, filterId), null);
130
+ });
131
+ });
@@ -0,0 +1,105 @@
1
+ import type {
2
+ CreateFilterAnchorInput,
3
+ FilterAnchorItem,
4
+ IFilterAnchorRepository,
5
+ } from "@remit/data-ports";
6
+ import { and, eq } from "drizzle-orm";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+ import { filterAnchorTable } from "../schema.js";
9
+
10
+ type DB = NodePgDatabase<Record<string, unknown>>;
11
+
12
+ function rowToFilterAnchor(
13
+ row: typeof filterAnchorTable.$inferSelect,
14
+ ): FilterAnchorItem {
15
+ return {
16
+ accountConfigId: row.accountConfigId,
17
+ filterId: row.filterId,
18
+ anchorEmbedding: row.anchorEmbedding as number[],
19
+ anchorEmbeddingId: row.anchorEmbeddingId,
20
+ anchorSourceText: row.anchorSourceText,
21
+ anchorMessageId: row.anchorMessageId,
22
+ createdAt: row.createdAt,
23
+ updatedAt: row.updatedAt,
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Postgres counterpart to `FilterAnchorService` (remit-electrodb-service). The
29
+ * anchor vector is persisted inline on the row (RFC 034 Decision 2.1/2.3); the
30
+ * pgvector store's per-message chunk vectors are read only by the search
31
+ * service when it builds the anchor, never re-derived here at match time.
32
+ */
33
+ export class FilterAnchorRepo implements IFilterAnchorRepository {
34
+ constructor(private db: DB) {}
35
+
36
+ /**
37
+ * Upsert — a filter's anchor is (re)computed once at save time (RFC 034
38
+ * Decision 2.1) and again only by an explicit re-embed migration
39
+ * (Decision 2.4).
40
+ */
41
+ async put(input: CreateFilterAnchorInput): Promise<FilterAnchorItem> {
42
+ const now = Date.now();
43
+ const [row] = await this.db
44
+ .insert(filterAnchorTable)
45
+ .values({
46
+ accountConfigId: input.accountConfigId,
47
+ filterId: input.filterId,
48
+ anchorEmbedding: input.anchorEmbedding,
49
+ anchorEmbeddingId: input.anchorEmbeddingId,
50
+ anchorSourceText: input.anchorSourceText,
51
+ anchorMessageId: input.anchorMessageId,
52
+ createdAt: now,
53
+ updatedAt: now,
54
+ })
55
+ .onConflictDoUpdate({
56
+ target: [filterAnchorTable.accountConfigId, filterAnchorTable.filterId],
57
+ set: {
58
+ anchorEmbedding: input.anchorEmbedding,
59
+ anchorEmbeddingId: input.anchorEmbeddingId,
60
+ anchorSourceText: input.anchorSourceText,
61
+ anchorMessageId: input.anchorMessageId,
62
+ updatedAt: now,
63
+ },
64
+ })
65
+ .returning();
66
+ return rowToFilterAnchor(row);
67
+ }
68
+
69
+ async get(
70
+ accountConfigId: string,
71
+ filterId: string,
72
+ ): Promise<FilterAnchorItem | null> {
73
+ const [row] = await this.db
74
+ .select()
75
+ .from(filterAnchorTable)
76
+ .where(
77
+ and(
78
+ eq(filterAnchorTable.accountConfigId, accountConfigId),
79
+ eq(filterAnchorTable.filterId, filterId),
80
+ ),
81
+ );
82
+ return row ? rowToFilterAnchor(row) : null;
83
+ }
84
+
85
+ async listByAccountConfig(
86
+ accountConfigId: string,
87
+ ): Promise<FilterAnchorItem[]> {
88
+ const rows = await this.db
89
+ .select()
90
+ .from(filterAnchorTable)
91
+ .where(eq(filterAnchorTable.accountConfigId, accountConfigId));
92
+ return rows.map(rowToFilterAnchor);
93
+ }
94
+
95
+ async delete(accountConfigId: string, filterId: string): Promise<void> {
96
+ await this.db
97
+ .delete(filterAnchorTable)
98
+ .where(
99
+ and(
100
+ eq(filterAnchorTable.accountConfigId, accountConfigId),
101
+ eq(filterAnchorTable.filterId, filterId),
102
+ ),
103
+ );
104
+ }
105
+ }
@@ -0,0 +1,279 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { FilterScope, FilterState } from "@remit/domain-enums";
4
+ import { NotFoundError } from "../error.js";
5
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
6
+ import { FilterRepo } from "./filter.js";
7
+
8
+ describe("FilterRepo", () => {
9
+ let db: TestDb;
10
+ let close: () => Promise<void>;
11
+ let repo: FilterRepo;
12
+
13
+ before(async () => {
14
+ ({ db, close } = await createTestDb());
15
+ repo = new FilterRepo(db as never);
16
+ });
17
+
18
+ after(async () => {
19
+ await close();
20
+ });
21
+
22
+ test("create fills in every defaulted field, never leaving it absent", async () => {
23
+ const accountConfigId = randomId();
24
+ const filter = await repo.create({
25
+ accountConfigId,
26
+ name: "Booking confirmations",
27
+ scope: FilterScope.Standing,
28
+ });
29
+
30
+ assert.equal(filter.state, FilterState.Active);
31
+ assert.equal(filter.hasAnchor, false);
32
+ assert.equal(filter.literalClauses.length, 0);
33
+ assert.equal(filter.actionLabelId, "None");
34
+ assert.equal(filter.actionMailboxId, "None");
35
+ assert.ok(
36
+ filter.ruleChangedAt > 0,
37
+ "ruleChangedAt should be set on create",
38
+ );
39
+ assert.equal(filter.expiresAt, undefined);
40
+ assert.equal(filter.ttl, undefined);
41
+ });
42
+
43
+ test("a Temporary filter round-trips expiresAt and ttl", async () => {
44
+ const accountConfigId = randomId();
45
+ const expiresAt = "2026-08-01T00:00:00+02:00";
46
+ const ttl = Math.floor(new Date(expiresAt).getTime() / 1000);
47
+
48
+ const filter = await repo.create({
49
+ accountConfigId,
50
+ name: "Until my trip",
51
+ scope: FilterScope.Temporary,
52
+ expiresAt,
53
+ ttl,
54
+ });
55
+
56
+ assert.equal(filter.scope, FilterScope.Temporary);
57
+ assert.equal(filter.expiresAt, expiresAt);
58
+ assert.equal(filter.ttl, ttl);
59
+ });
60
+
61
+ test("create round-trips literal clauses", async () => {
62
+ const accountConfigId = randomId();
63
+ const literalClauses = [
64
+ { field: "From", value: "billing@example.com" },
65
+ { field: "Subject", value: "invoice" },
66
+ ] as const;
67
+
68
+ const filter = await repo.create({
69
+ accountConfigId,
70
+ name: "Invoices",
71
+ scope: FilterScope.Standing,
72
+ literalClauses: [...literalClauses],
73
+ });
74
+
75
+ assert.deepEqual(filter.literalClauses, literalClauses);
76
+ });
77
+
78
+ test("get throws NotFoundError for a missing filter", async () => {
79
+ await assert.rejects(repo.get(randomId(), randomId()), NotFoundError);
80
+ });
81
+
82
+ test("update does not bump ruleChangedAt on a cosmetic rename", async () => {
83
+ const accountConfigId = randomId();
84
+ const filter = await repo.create({
85
+ accountConfigId,
86
+ name: "Newsletter admin",
87
+ scope: FilterScope.Standing,
88
+ });
89
+
90
+ const renamed = await repo.update(accountConfigId, filter.filterId, {
91
+ name: "Newsletter admin (renamed)",
92
+ });
93
+
94
+ assert.equal(renamed.name, "Newsletter admin (renamed)");
95
+ assert.equal(
96
+ renamed.ruleChangedAt,
97
+ filter.ruleChangedAt,
98
+ "a name-only edit must not move ruleChangedAt (RFC 034 Decision 3.2)",
99
+ );
100
+ });
101
+
102
+ test("update bumps ruleChangedAt when the action changes", async () => {
103
+ const accountConfigId = randomId();
104
+ const filter = await repo.create({
105
+ accountConfigId,
106
+ name: "Move to archive",
107
+ scope: FilterScope.Standing,
108
+ });
109
+
110
+ await new Promise((resolve) => setTimeout(resolve, 1100));
111
+
112
+ const updated = await repo.update(accountConfigId, filter.filterId, {
113
+ actionMailboxId: randomId(),
114
+ });
115
+
116
+ assert.ok(
117
+ updated.ruleChangedAt > filter.ruleChangedAt,
118
+ "an action edit must move ruleChangedAt forward",
119
+ );
120
+ });
121
+
122
+ test("update throws NotFoundError for a missing filter", async () => {
123
+ await assert.rejects(
124
+ repo.update(randomId(), randomId(), { name: "x" }),
125
+ NotFoundError,
126
+ );
127
+ });
128
+
129
+ test("listByAccountConfig and listByAccountAndState scope to the account", async () => {
130
+ const accountConfigId = randomId();
131
+ const other = randomId();
132
+
133
+ const standing = await repo.create({
134
+ accountConfigId,
135
+ name: "Standing",
136
+ scope: FilterScope.Standing,
137
+ });
138
+ await repo.create({
139
+ accountConfigId: other,
140
+ name: "Foreign",
141
+ scope: FilterScope.Standing,
142
+ });
143
+
144
+ const all = await repo.listByAccountConfig(accountConfigId);
145
+ assert.equal(all.length, 1);
146
+ assert.equal(all[0]?.filterId, standing.filterId);
147
+
148
+ const active = await repo.listByAccountAndState(
149
+ accountConfigId,
150
+ FilterState.Active,
151
+ );
152
+ assert.equal(active.length, 1);
153
+ assert.equal(active[0]?.filterId, standing.filterId);
154
+
155
+ const expired = await repo.listByAccountAndState(
156
+ accountConfigId,
157
+ FilterState.Expired,
158
+ );
159
+ assert.equal(expired.length, 0);
160
+ });
161
+
162
+ test("refreshExpiry leaves a Standing filter untouched", async () => {
163
+ const accountConfigId = randomId();
164
+ const filter = await repo.create({
165
+ accountConfigId,
166
+ name: "Standing",
167
+ scope: FilterScope.Standing,
168
+ });
169
+
170
+ const result = await repo.refreshExpiry(filter);
171
+ assert.equal(result.state, FilterState.Active);
172
+ });
173
+
174
+ test("refreshExpiry leaves a not-yet-expired Temporary filter Active", async () => {
175
+ const accountConfigId = randomId();
176
+ const future = new Date(Date.now() + 86_400_000).toISOString();
177
+ const filter = await repo.create({
178
+ accountConfigId,
179
+ name: "Until tomorrow",
180
+ scope: FilterScope.Temporary,
181
+ expiresAt: future,
182
+ ttl: Math.floor(new Date(future).getTime() / 1000),
183
+ });
184
+
185
+ const result = await repo.refreshExpiry(filter);
186
+ assert.equal(result.state, FilterState.Active);
187
+ });
188
+
189
+ test("refreshExpiry lazily patches a past-expiresAt Temporary filter to Expired", async () => {
190
+ const accountConfigId = randomId();
191
+ const past = new Date(Date.now() - 86_400_000).toISOString();
192
+ const filter = await repo.create({
193
+ accountConfigId,
194
+ name: "Until yesterday",
195
+ scope: FilterScope.Temporary,
196
+ expiresAt: past,
197
+ ttl: Math.floor(new Date(past).getTime() / 1000) + 172_800,
198
+ });
199
+
200
+ assert.equal(filter.state, FilterState.Active, "starts Active on create");
201
+
202
+ const result = await repo.refreshExpiry(filter);
203
+ assert.equal(result.state, FilterState.Expired);
204
+
205
+ const reread = await repo.get(accountConfigId, filter.filterId);
206
+ assert.equal(
207
+ reread.state,
208
+ FilterState.Expired,
209
+ "the patch is persisted, not just returned",
210
+ );
211
+ });
212
+
213
+ test("delete removes the row", async () => {
214
+ const accountConfigId = randomId();
215
+ const filter = await repo.create({
216
+ accountConfigId,
217
+ name: "To delete",
218
+ scope: FilterScope.Standing,
219
+ });
220
+
221
+ await repo.delete(accountConfigId, filter.filterId);
222
+
223
+ await assert.rejects(
224
+ repo.get(accountConfigId, filter.filterId),
225
+ NotFoundError,
226
+ );
227
+ });
228
+
229
+ test("listPageByAccountConfig round-trips a continuationToken across pages", async () => {
230
+ const accountConfigId = randomId();
231
+ const created = await Promise.all([
232
+ repo.create({
233
+ accountConfigId,
234
+ name: "One",
235
+ scope: FilterScope.Standing,
236
+ }),
237
+ repo.create({
238
+ accountConfigId,
239
+ name: "Two",
240
+ scope: FilterScope.Standing,
241
+ }),
242
+ repo.create({
243
+ accountConfigId,
244
+ name: "Three",
245
+ scope: FilterScope.Standing,
246
+ }),
247
+ ]);
248
+
249
+ const first = await repo.listPageByAccountConfig(accountConfigId, {
250
+ limit: 2,
251
+ });
252
+ assert.equal(first.items.length, 2);
253
+ assert.ok(
254
+ first.continuationToken,
255
+ "a full page returns a continuationToken",
256
+ );
257
+
258
+ const second = await repo.listPageByAccountConfig(accountConfigId, {
259
+ limit: 2,
260
+ continuationToken: first.continuationToken,
261
+ });
262
+ assert.equal(second.items.length, 1);
263
+ assert.equal(
264
+ second.continuationToken,
265
+ undefined,
266
+ "the last page carries no continuationToken",
267
+ );
268
+
269
+ const seen = new Set([
270
+ ...first.items.map((f) => f.filterId),
271
+ ...second.items.map((f) => f.filterId),
272
+ ]);
273
+ assert.equal(
274
+ seen.size,
275
+ created.length,
276
+ "every filter is paged exactly once",
277
+ );
278
+ });
279
+ });