@remit/drizzle-service 0.0.40 → 0.0.42

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.40",
3
+ "version": "0.0.42",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "scripts": {
15
15
  "test:typecheck": "tsgo --noEmit -p tsconfig.json",
16
- "test:run": "node --env-file=../../localhost-test-unit-sqlite.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=90 --test 'src/**/*.test.ts'",
16
+ "test:run": "node $NODE_TEST_FLAGS --env-file=../../localhost-test-unit-sqlite.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=90 --test 'src/**/*.test.ts'",
17
17
  "test": "npm run test:typecheck && npm run test:run",
18
18
  "fix": "biome check --write src"
19
19
  },
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  export * from "./repos/i4-organize-job-request.js";
34
34
  export { OutboxAttachmentRepo } from "./repos/i4-outbox-attachment.js";
35
35
  export * from "./repos/i4-outbox-message.js";
36
+ export { SenderSignerStandingRepo } from "./repos/i4-sender-signer-standing.js";
36
37
  export { LabelRepo } from "./repos/label.js";
37
38
  export {
38
39
  DrizzleMessageRepository,
@@ -98,6 +98,45 @@ describe("AddressRepo", () => {
98
98
  await repo.deleteAddress(addr.accountConfigId, addr.addressId);
99
99
  });
100
100
 
101
+ test("three inbound messages promote a person-shaped sender to wellknown", async () => {
102
+ const addr = await repo.createAddress(makeAddressInput(randomId()));
103
+ const now = Date.now();
104
+
105
+ for (let i = 0; i < 3; i++) {
106
+ await repo.incrementInboundCount(
107
+ addr.accountConfigId,
108
+ addr.addressId,
109
+ now,
110
+ false,
111
+ );
112
+ }
113
+
114
+ const updated = await repo.getAddress(addr.accountConfigId, addr.addressId);
115
+ assert.equal(updated.flags?.wellknown?.value, true);
116
+
117
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
118
+ });
119
+
120
+ test("a bulk sender never reaches wellknown on inbound volume alone", async () => {
121
+ const addr = await repo.createAddress(makeAddressInput(randomId()));
122
+ const now = Date.now();
123
+
124
+ for (let i = 0; i < 5; i++) {
125
+ await repo.incrementInboundCount(
126
+ addr.accountConfigId,
127
+ addr.addressId,
128
+ now,
129
+ true,
130
+ );
131
+ }
132
+
133
+ const updated = await repo.getAddress(addr.accountConfigId, addr.addressId);
134
+ assert.equal(updated.inboundCount, 5);
135
+ assert.equal(updated.flags?.wellknown, undefined);
136
+
137
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
138
+ });
139
+
101
140
  test("createEnvelopeAddress and getEnvelopeAddress", async () => {
102
141
  const messageId = randomUUID();
103
142
  const addressId = randomUUID();
@@ -396,13 +396,14 @@ export class AddressRepo implements IAddressRepository {
396
396
  accountConfigId: string,
397
397
  addressId: string,
398
398
  now: number,
399
- _isBulk?: boolean,
399
+ isBulk?: boolean,
400
400
  ): Promise<void> {
401
401
  const current = await this.getAddress(accountConfigId, addressId);
402
402
  const post = {
403
403
  ...current,
404
404
  inboundCount: (current.inboundCount ?? 0) + 1,
405
405
  lastInboundAt: now,
406
+ isBulk: isBulk ?? false,
406
407
  };
407
408
  if (shouldPromoteWellknown(post, now)) {
408
409
  const nextFlags: AddressFlags = {
@@ -0,0 +1,188 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { eq } from "drizzle-orm";
4
+ import { NotFoundError } from "../error.js";
5
+ import { senderSignerStandingTable } from "../schema.js";
6
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
7
+ import { SenderSignerStandingRepo } from "./i4-sender-signer-standing.js";
8
+
9
+ describe("SenderSignerStandingRepo", () => {
10
+ let db: TestDb;
11
+ let close: () => Promise<void>;
12
+ let repo: SenderSignerStandingRepo;
13
+
14
+ const rowsFor = async (accountConfigId: string) =>
15
+ db
16
+ .select()
17
+ .from(senderSignerStandingTable)
18
+ .where(eq(senderSignerStandingTable.accountConfigId, accountConfigId));
19
+
20
+ before(async () => {
21
+ ({ db, close } = await createTestDb());
22
+ repo = new SenderSignerStandingRepo(db as never);
23
+ });
24
+
25
+ after(async () => {
26
+ await close();
27
+ });
28
+
29
+ test("observing a new key inserts one row at a count of one", async () => {
30
+ const accountConfigId = randomId();
31
+ const observedAt = 1_700_000_000_000;
32
+
33
+ const standing = await repo.observe({
34
+ accountConfigId,
35
+ senderKey: "vip.example",
36
+ signerDomain: "esp.example",
37
+ observedAt,
38
+ });
39
+
40
+ assert.equal(standing.messageCount, 1);
41
+ assert.equal(standing.firstSeenAt, observedAt);
42
+ assert.equal(standing.lastSeenAt, observedAt);
43
+ assert.equal(standing.userAffirmedAt, 0);
44
+ assert.equal((await rowsFor(accountConfigId)).length, 1);
45
+ });
46
+
47
+ test("observing the same key again increments the count in place rather than inserting a second row", async () => {
48
+ const accountConfigId = randomId();
49
+ const key = {
50
+ accountConfigId,
51
+ senderKey: "vip.example",
52
+ signerDomain: "esp.example",
53
+ };
54
+
55
+ await repo.observe({ ...key, observedAt: 1_700_000_000_000 });
56
+ await repo.observe({ ...key, observedAt: 1_700_000_060_000 });
57
+ const third = await repo.observe({
58
+ ...key,
59
+ observedAt: 1_700_000_120_000,
60
+ });
61
+
62
+ assert.equal(third.messageCount, 3);
63
+ const rows = await rowsFor(accountConfigId);
64
+ assert.equal(rows.length, 1);
65
+ assert.equal(rows[0]?.messageCount, 3);
66
+ });
67
+
68
+ // The failure this guards is silent: naming first_seen_at in the conflict
69
+ // `set` makes every message reset the key's age, so standing reads as
70
+ // brand-new forever and nothing downstream can tell.
71
+ test("a repeat leaves firstSeenAt at the first observation while lastSeenAt follows the latest", async () => {
72
+ const accountConfigId = randomId();
73
+ const key = {
74
+ accountConfigId,
75
+ senderKey: "list.example",
76
+ signerDomain: "unverified",
77
+ };
78
+ const first = 1_600_000_000_000;
79
+
80
+ await repo.observe({ ...key, observedAt: first });
81
+ await repo.observe({ ...key, observedAt: first + 3_600_000 });
82
+ const latest = await repo.observe({
83
+ ...key,
84
+ observedAt: first + 7_200_000,
85
+ });
86
+
87
+ assert.equal(latest.firstSeenAt, first);
88
+ assert.equal(latest.lastSeenAt, first + 7_200_000);
89
+
90
+ const [row] = await rowsFor(accountConfigId);
91
+ assert.equal(row?.firstSeenAt, first);
92
+ assert.equal(row?.lastSeenAt, first + 7_200_000);
93
+ });
94
+
95
+ test("an out-of-order observation still counts, and never rewrites firstSeenAt", async () => {
96
+ const accountConfigId = randomId();
97
+ const key = {
98
+ accountConfigId,
99
+ senderKey: "delayed.example",
100
+ signerDomain: "esp.example",
101
+ };
102
+ const first = 1_650_000_000_000;
103
+
104
+ await repo.observe({ ...key, observedAt: first });
105
+ const older = await repo.observe({
106
+ ...key,
107
+ observedAt: first - 86_400_000,
108
+ });
109
+
110
+ assert.equal(older.messageCount, 2);
111
+ assert.equal(older.firstSeenAt, first);
112
+ });
113
+
114
+ test("the same sender under two signer domains keeps two independent rows", async () => {
115
+ const accountConfigId = randomId();
116
+ const observedAt = 1_700_000_000_000;
117
+
118
+ await repo.observe({
119
+ accountConfigId,
120
+ senderKey: "shop.example",
121
+ signerDomain: "esp-one.example",
122
+ observedAt,
123
+ });
124
+ await repo.observe({
125
+ accountConfigId,
126
+ senderKey: "shop.example",
127
+ signerDomain: "esp-one.example",
128
+ observedAt,
129
+ });
130
+ const other = await repo.observe({
131
+ accountConfigId,
132
+ senderKey: "shop.example",
133
+ signerDomain: "esp-two.example",
134
+ observedAt,
135
+ });
136
+
137
+ assert.equal(other.messageCount, 1);
138
+ assert.equal((await rowsFor(accountConfigId)).length, 2);
139
+ });
140
+
141
+ test("standing never crosses accounts", async () => {
142
+ const mine = randomId();
143
+ const theirs = randomId();
144
+ const key = {
145
+ senderKey: "shared.example",
146
+ signerDomain: "esp.example",
147
+ observedAt: 1_700_000_000_000,
148
+ };
149
+
150
+ await repo.observe({ accountConfigId: mine, ...key });
151
+ await repo.observe({ accountConfigId: mine, ...key });
152
+ const foreign = await repo.observe({ accountConfigId: theirs, ...key });
153
+
154
+ assert.equal(foreign.messageCount, 1);
155
+ assert.equal(
156
+ (await repo.get(mine, key.senderKey, key.signerDomain)).messageCount,
157
+ 2,
158
+ );
159
+ });
160
+
161
+ test("get raises NotFoundError for a key that was never observed", async () => {
162
+ await assert.rejects(
163
+ repo.get(randomId(), "stranger.example", "esp.example"),
164
+ (error) => error instanceof NotFoundError,
165
+ );
166
+ });
167
+
168
+ test("get reads back the row the last observation returned", async () => {
169
+ const accountConfigId = randomId();
170
+ const key = {
171
+ accountConfigId,
172
+ senderKey: "readback.example",
173
+ signerDomain: "esp.example",
174
+ };
175
+
176
+ const written = await repo.observe({
177
+ ...key,
178
+ observedAt: 1_700_000_000_000,
179
+ });
180
+ const read = await repo.get(
181
+ accountConfigId,
182
+ key.senderKey,
183
+ key.signerDomain,
184
+ );
185
+
186
+ assert.deepEqual(read, written);
187
+ });
188
+ });
@@ -0,0 +1,101 @@
1
+ import type {
2
+ ISenderSignerStandingRepository,
3
+ ObserveSenderSignerStandingInput,
4
+ SenderSignerStandingItem,
5
+ } from "@remit/data-ports";
6
+ import { and, eq, sql } from "drizzle-orm";
7
+ import type { Db } from "../db.js";
8
+ import { NotFoundError } from "../error.js";
9
+ import { senderSignerStandingTable } from "../schema.js";
10
+
11
+ type DB = Db<Record<string, unknown>>;
12
+
13
+ function rowToStanding(
14
+ row: typeof senderSignerStandingTable.$inferSelect,
15
+ ): SenderSignerStandingItem {
16
+ return {
17
+ accountConfigId: row.accountConfigId,
18
+ senderKey: row.senderKey,
19
+ signerDomain: row.signerDomain,
20
+ messageCount: row.messageCount,
21
+ firstSeenAt: row.firstSeenAt,
22
+ lastSeenAt: row.lastSeenAt,
23
+ userAffirmedAt: row.userAffirmedAt,
24
+ createdAt: row.createdAt,
25
+ updatedAt: row.updatedAt,
26
+ };
27
+ }
28
+
29
+ export class SenderSignerStandingRepo
30
+ implements ISenderSignerStandingRepository
31
+ {
32
+ constructor(private db: DB) {}
33
+
34
+ async observe(
35
+ input: ObserveSenderSignerStandingInput,
36
+ ): Promise<SenderSignerStandingItem> {
37
+ const now = Date.now();
38
+ // `first_seen_at` is deliberately absent from the conflict `set`.
39
+ // onConflictDoUpdate writes only the columns it names, so naming it here
40
+ // would let every message reset the key's age to its own arrival and the
41
+ // standing this row exists to record would never be older than the last
42
+ // message. The mirror-image mistake — a conflict path that must reset a
43
+ // timestamp and would silently inherit the old one by omitting it — is at
44
+ // i4-message-flag-push.ts:70.
45
+ const [row] = await this.db
46
+ .insert(senderSignerStandingTable)
47
+ .values({
48
+ accountConfigId: input.accountConfigId,
49
+ senderKey: input.senderKey,
50
+ signerDomain: input.signerDomain,
51
+ messageCount: 1,
52
+ firstSeenAt: input.observedAt,
53
+ lastSeenAt: input.observedAt,
54
+ userAffirmedAt: 0,
55
+ createdAt: now,
56
+ updatedAt: now,
57
+ })
58
+ .onConflictDoUpdate({
59
+ target: [
60
+ senderSignerStandingTable.accountConfigId,
61
+ senderSignerStandingTable.senderKey,
62
+ senderSignerStandingTable.signerDomain,
63
+ ],
64
+ set: {
65
+ messageCount: sql`${senderSignerStandingTable.messageCount} + 1`,
66
+ lastSeenAt: input.observedAt,
67
+ updatedAt: now,
68
+ },
69
+ })
70
+ .returning();
71
+ if (!row) {
72
+ throw new Error(
73
+ `Failed to upsert SenderSignerStanding: ${input.accountConfigId}/${input.senderKey}/${input.signerDomain}`,
74
+ );
75
+ }
76
+ return rowToStanding(row);
77
+ }
78
+
79
+ async get(
80
+ accountConfigId: string,
81
+ senderKey: string,
82
+ signerDomain: string,
83
+ ): Promise<SenderSignerStandingItem> {
84
+ const [row] = await this.db
85
+ .select()
86
+ .from(senderSignerStandingTable)
87
+ .where(
88
+ and(
89
+ eq(senderSignerStandingTable.accountConfigId, accountConfigId),
90
+ eq(senderSignerStandingTable.senderKey, senderKey),
91
+ eq(senderSignerStandingTable.signerDomain, signerDomain),
92
+ ),
93
+ );
94
+ if (!row) {
95
+ throw new NotFoundError(
96
+ `SenderSignerStanding not found: ${senderKey}/${signerDomain}`,
97
+ );
98
+ }
99
+ return rowToStanding(row);
100
+ }
101
+ }
@@ -59,6 +59,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
59
59
  status: row.status,
60
60
  syncStatus: row.syncStatus,
61
61
  category: row.category,
62
+ authenticityVerdict: row.authenticityVerdict,
62
63
  hasListUnsubscribe: row.hasListUnsubscribe,
63
64
  movedByRemit: row.movedByRemit,
64
65
  createdAt: row.createdAt,
@@ -184,6 +185,8 @@ export class DrizzleMessageRepository implements IMessageRepository {
184
185
  status: input.status ?? ("active" as const),
185
186
  syncStatus: input.syncStatus ?? ("pending" as const),
186
187
  category: input.category ?? ("uncategorized" as const),
188
+ authenticityVerdict:
189
+ input.authenticityVerdict ?? ("NotEvaluated" as const),
187
190
  hasListUnsubscribe: input.hasListUnsubscribe ?? false,
188
191
  movedByRemit: input.movedByRemit ?? false,
189
192
  messageIdHeader: input.messageIdHeader ?? null,
@@ -365,6 +368,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
365
368
  ? { syncStatus: input.syncStatus }
366
369
  : {}),
367
370
  ...(input.category !== undefined ? { category: input.category } : {}),
371
+ ...(input.authenticityVerdict !== undefined
372
+ ? { authenticityVerdict: input.authenticityVerdict }
373
+ : {}),
368
374
  ...(input.hasListUnsubscribe !== undefined
369
375
  ? { hasListUnsubscribe: input.hasListUnsubscribe }
370
376
  : {}),
@@ -0,0 +1,23 @@
1
+ import { senderSignerStandingRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { senderSignerStandingTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { SenderSignerStandingRepo } from "./i4-sender-signer-standing.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ senderSignerStandingRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ senderSignerStandings: senderSignerStandingTable,
14
+ });
15
+ close = closeDb;
16
+ return new SenderSignerStandingRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
package/src/schema.ts CHANGED
@@ -14,6 +14,7 @@ export const labelTable = entities.labels;
14
14
  export const mailboxAttributeEntryTable = entities.mailboxAttributeEntries;
15
15
  export const mailboxFlagTable = entities.mailboxFlags;
16
16
  export const messageLabelTable = entities.messageLabels;
17
+ export const senderSignerStandingTable = entities.senderSignerStandings;
17
18
  export * from "./schema/i4-account-config.js";
18
19
  export * from "./schema/i4-account-export-request.js";
19
20
  export * from "./schema/i4-account-setting.js";