@remit/drizzle-service 0.0.84 → 0.0.85

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.85",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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