@remit/drizzle-service 0.0.84 → 0.0.86

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.84",
3
+ "version": "0.0.86",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { AccountService } from "@remit/domain-enums";
4
+ import Database from "better-sqlite3";
5
+ import { drizzle } from "drizzle-orm/better-sqlite3";
6
+ import { AccountRepo } from "./repos/i4-account.js";
7
+ import { accountTable } from "./schema.js";
8
+ import {
9
+ applyMigration,
10
+ migrationJournal,
11
+ } from "./test-shipped-sqlite-schema.js";
12
+
13
+ const MIGRATION = "0029_account_synced_services";
14
+
15
+ const atPredecessor = (): Database.Database => {
16
+ const sqlite = new Database(":memory:");
17
+ for (const entry of migrationJournal()) {
18
+ if (entry.tag === MIGRATION) return sqlite;
19
+ applyMigration(sqlite, entry.tag);
20
+ }
21
+ throw new Error(`${MIGRATION} is not in the journal`);
22
+ };
23
+
24
+ describe("an account written before the service selection existed", () => {
25
+ test("comes out of the migration syncing mail only", async () => {
26
+ const sqlite = atPredecessor();
27
+ sqlite
28
+ .prepare(
29
+ `INSERT INTO account (
30
+ account_id, account_config_id, username, email, auth_type,
31
+ imap_host, imap_port, imap_tls, imap_start_tls, smtp_port,
32
+ is_active, connection_state, created_at, updated_at
33
+ ) VALUES ('existing', 'config', 'person', 'person@example.com',
34
+ 'password', 'imap.example.com', 993, 1, 0, 587, 1,
35
+ 'not_authenticated', 0, 0)`,
36
+ )
37
+ .run();
38
+
39
+ applyMigration(sqlite, MIGRATION);
40
+
41
+ const repo = new AccountRepo(
42
+ drizzle(sqlite, { schema: { accounts: accountTable } }) as never,
43
+ );
44
+ assert.deepEqual((await repo.get("existing")).syncedServices, [
45
+ AccountService.Mail,
46
+ ]);
47
+ sqlite.close();
48
+ });
49
+ });
@@ -0,0 +1,23 @@
1
+ import { accountRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { accountTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { AccountRepo } from "./i4-account.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ accountRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ accounts: accountTable,
14
+ });
15
+ close = closeDb;
16
+ return new AccountRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -58,6 +58,7 @@ describe("createSqliteCascadeDeleter", () => {
58
58
  username: "u",
59
59
  email: "u@example.com",
60
60
  authType: "password",
61
+ syncedServices: ["Mail"],
61
62
  imapHost: "imap.example.com",
62
63
  imapPort: 993,
63
64
  imapTls: true,
@@ -56,6 +56,7 @@ describe("runDrizzleCascadeDelete", () => {
56
56
  username: "u",
57
57
  email: "u@example.com",
58
58
  authType: "password",
59
+ syncedServices: ["Mail"],
59
60
  imapHost: "imap.example.com",
60
61
  imapPort: 993,
61
62
  imapTls: true,
@@ -7,7 +7,7 @@ import type {
7
7
  ResultList,
8
8
  UpdateAccountInput,
9
9
  } from "@remit/data-ports";
10
- import { SyncPhase } from "@remit/domain-enums";
10
+ import { AccountService, SyncPhase } from "@remit/domain-enums";
11
11
  import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm";
12
12
  import type { Db } from "../db.js";
13
13
  import { NotFoundError } from "../error.js";
@@ -39,6 +39,7 @@ export function rowToAccount(
39
39
  username: row.username,
40
40
  email: row.email,
41
41
  authType: row.authType as AccountItem["authType"],
42
+ syncedServices: row.syncedServices as AccountItem["syncedServices"],
42
43
  passwordHash: row.passwordHash ?? undefined,
43
44
  oauthRefreshTokenHash: row.oauthRefreshTokenHash ?? undefined,
44
45
  oauthTokenUpdatedAt: row.oauthTokenUpdatedAt ?? undefined,
@@ -81,6 +82,7 @@ export class AccountRepo implements IAccountRepository {
81
82
  username: input.username,
82
83
  email: input.email,
83
84
  authType: input.authType ?? "password",
85
+ syncedServices: input.syncedServices ?? [AccountService.Mail],
84
86
  passwordHash: input.passwordHash,
85
87
  oauthRefreshTokenHash: input.oauthRefreshTokenHash,
86
88
  oauthTokenUpdatedAt: input.oauthTokenUpdatedAt,
@@ -144,6 +146,8 @@ export class AccountRepo implements IAccountRepository {
144
146
  if (input.username !== undefined) updates.username = input.username;
145
147
  if (input.email !== undefined) updates.email = input.email;
146
148
  if (input.authType !== undefined) updates.authType = input.authType;
149
+ if (input.syncedServices !== undefined)
150
+ updates.syncedServices = input.syncedServices;
147
151
  if (input.passwordHash !== undefined)
148
152
  updates.passwordHash = input.passwordHash;
149
153
  if (input.oauthRefreshTokenHash !== undefined)
@@ -20,9 +20,9 @@ describe("reconciling one message's addresses at the moment it moves", () => {
20
20
  `INSERT OR IGNORE INTO account (
21
21
  account_id, account_config_id, username, email, imap_host,
22
22
  imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
23
- connection_state, created_at, updated_at
23
+ connection_state, synced_services, created_at, updated_at
24
24
  ) VALUES (?, ?, 'user', 'user@example.com', 'imap.example.com',
25
- 993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
25
+ 993, 1, 0, 465, 1, 'disconnected', '["Mail"]', 0, 0)`,
26
26
  )
27
27
  .run(accountId, CONFIG);
28
28
  };
@@ -75,6 +75,137 @@ describe("concurrent flag merges on one address", () => {
75
75
  await repo.deleteAddress(addr.accountConfigId, addr.addressId);
76
76
  });
77
77
 
78
+ test("marking a blocked sender never-spam drops the block", async () => {
79
+ const addr = await address();
80
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
81
+ blocked: { value: true, setAt: 1 },
82
+ muted: { value: true, setAt: 1 },
83
+ });
84
+
85
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
86
+ neverSpam: { value: true, setAt: 2 },
87
+ });
88
+
89
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
90
+ assert.equal(merged.flags?.neverSpam?.value, true);
91
+ assert.equal(merged.flags?.blocked, undefined);
92
+ assert.equal(
93
+ merged.flags?.muted?.value,
94
+ true,
95
+ "a flag on another axis is untouched",
96
+ );
97
+
98
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
99
+ });
100
+
101
+ test("blocking a never-spam sender drops the grant", async () => {
102
+ const addr = await address();
103
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
104
+ neverSpam: { value: true, setAt: 1 },
105
+ });
106
+
107
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
108
+ blocked: { value: true, setAt: 2 },
109
+ });
110
+
111
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
112
+ assert.equal(merged.flags?.blocked?.value, true);
113
+ assert.equal(merged.flags?.neverSpam, undefined);
114
+
115
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
116
+ });
117
+
118
+ test("a never-spam grant turned off leaves a later block alone", async () => {
119
+ const addr = await address();
120
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
121
+ neverSpam: { value: false, setAt: 1 },
122
+ });
123
+
124
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
125
+ blocked: { value: true, setAt: 2 },
126
+ });
127
+
128
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
129
+ assert.equal(merged.flags?.blocked?.value, true);
130
+ assert.equal(
131
+ merged.flags?.neverSpam?.value,
132
+ false,
133
+ "an explicit false is not a contradiction and keeps its audit trail",
134
+ );
135
+
136
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
137
+ });
138
+
139
+ // A row written before the invariant existed still carries both. Healing it
140
+ // while patching an unrelated key would revoke a placement instruction the
141
+ // user never touched, so the write leaves it and the read arbitrates.
142
+ test("a patch about another flag leaves a legacy both-set row alone", async () => {
143
+ const accountConfigId = randomId();
144
+ const addr = await repo.createAddress({
145
+ addressId: randomId(),
146
+ accountConfigId,
147
+ localPart: "legacy",
148
+ domain: "example.com",
149
+ normalizedEmail: "legacy@example.com",
150
+ normalizedCompound: "legacy@example.com:legacy",
151
+ flags: {
152
+ blocked: { value: true, setAt: 1 },
153
+ neverSpam: { value: true, setAt: 2 },
154
+ },
155
+ });
156
+
157
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
158
+ muted: { value: true, setAt: 3 },
159
+ });
160
+
161
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
162
+ assert.equal(merged.flags?.blocked?.value, true);
163
+ assert.equal(merged.flags?.neverSpam?.value, true);
164
+ assert.equal(merged.flags?.muted?.value, true);
165
+
166
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
167
+ });
168
+
169
+ test("writing either placement flag heals a legacy both-set row", async () => {
170
+ const accountConfigId = randomId();
171
+ const addr = await repo.createAddress({
172
+ addressId: randomId(),
173
+ accountConfigId,
174
+ localPart: "legacy",
175
+ domain: "example.com",
176
+ normalizedEmail: "legacy2@example.com",
177
+ normalizedCompound: "legacy2@example.com:legacy",
178
+ flags: {
179
+ blocked: { value: true, setAt: 1 },
180
+ neverSpam: { value: true, setAt: 2 },
181
+ },
182
+ });
183
+
184
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
185
+ neverSpam: { value: true, setAt: 3 },
186
+ });
187
+
188
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
189
+ assert.equal(merged.flags?.neverSpam?.value, true);
190
+ assert.equal(merged.flags?.blocked, undefined);
191
+
192
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
193
+ });
194
+
195
+ test("one patch naming both contradictory flags settles on the block", async () => {
196
+ const addr = await address();
197
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
198
+ blocked: { value: true, setAt: 1 },
199
+ neverSpam: { value: true, setAt: 1 },
200
+ });
201
+
202
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
203
+ assert.equal(merged.flags?.blocked?.value, true);
204
+ assert.equal(merged.flags?.neverSpam, undefined);
205
+
206
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
207
+ });
208
+
78
209
  test("a concurrent merge does not resurrect a deleted flag", async () => {
79
210
  const addr = await address();
80
211
  await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
@@ -70,6 +70,41 @@ type MergeAttempt =
70
70
  | { outcome: "missing" }
71
71
  | { outcome: "contended" };
72
72
 
73
+ /**
74
+ * `blocked` and `neverSpam` are two directly contradictory statements about
75
+ * where a sender's mail belongs (issue #605), so a row asserting both is not a
76
+ * tie to break — it is a row that should not exist. This is the only place that
77
+ * sees both keys at once: `buildFlagsPatch` stays a pure per-key translation,
78
+ * and the merge fold is the read-modify-write of the whole map.
79
+ *
80
+ * The flag the patch just raised wins, so the instruction the user gave last is
81
+ * the one that stands. A patch raising both resolves to `blocked` — the same
82
+ * direction the `blocked`/`vip` same-second tie already breaks in.
83
+ *
84
+ * Only a patch that raises one of the two is allowed to drop the other. A row
85
+ * that somehow already carries both survives a patch about anything else
86
+ * untouched: dropping a placement instruction as a side effect of writing
87
+ * `muted` would be a silent revocation the user never asked for. Such a row is
88
+ * resolved on read — `resolveSenderPlacement` reads it as `Blocked` — and
89
+ * healed the next time either key is written.
90
+ */
91
+ const dropContradictedPlacementFlag = (
92
+ next: AddressFlags,
93
+ patch: FlagsMergePatch,
94
+ ): AddressFlags => {
95
+ if (next.blocked?.value !== true || next.neverSpam?.value !== true)
96
+ return next;
97
+ if (patch.blocked?.value === true) {
98
+ const { neverSpam: _neverSpam, ...rest } = next;
99
+ return rest;
100
+ }
101
+ if (patch.neverSpam?.value === true) {
102
+ const { blocked: _blocked, ...rest } = next;
103
+ return rest;
104
+ }
105
+ return next;
106
+ };
107
+
73
108
  /**
74
109
  * The stored `"<display name> <email>"` compound, folded in JavaScript exactly
75
110
  * as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
@@ -496,7 +531,7 @@ export class AddressRepo implements IAddressRepository {
496
531
  }
497
532
  (next[key] as AddressFlags[keyof AddressFlags]) = value;
498
533
  }
499
- return next;
534
+ return dropContradictedPlacementFlag(next, patch);
500
535
  });
501
536
  }
502
537
 
@@ -82,6 +82,7 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
82
82
  imapStartTls: false,
83
83
  isActive: true,
84
84
  connectionState: "authenticated",
85
+ syncedServices: ["Mail"],
85
86
  createdAt: now,
86
87
  updatedAt: now,
87
88
  } as never);