@remit/drizzle-service 0.0.57 → 0.0.59

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.57",
3
+ "version": "0.0.59",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, test } from "node:test";
3
+ import type { AddressFlags } from "@remit/data-ports";
4
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
5
+ import { serializeSqliteWrites } from "../tx.js";
6
+ import { AddressRepo } from "./i4-address.js";
7
+
8
+ const FLAG_KEYS = [
9
+ "trusted",
10
+ "blocked",
11
+ "muted",
12
+ "vip",
13
+ "wellknown",
14
+ "junkOnly",
15
+ "autoArchive",
16
+ "unsubscribed",
17
+ ] as const;
18
+
19
+ const CONCURRENT_CALLS = 50;
20
+
21
+ describe("concurrent flag merges on one address", () => {
22
+ let db: TestDb;
23
+ let close: () => Promise<void>;
24
+ let repo: AddressRepo;
25
+
26
+ before(async () => {
27
+ ({ db, close } = await createTestDb());
28
+ repo = new AddressRepo(serializeSqliteWrites(db) as never);
29
+ });
30
+
31
+ after(async () => {
32
+ await close();
33
+ });
34
+
35
+ const address = async () => {
36
+ const accountConfigId = randomId();
37
+ return repo.createAddress({
38
+ addressId: randomId(),
39
+ accountConfigId,
40
+ localPart: "sender",
41
+ domain: "example.com",
42
+ normalizedEmail: "sender@example.com",
43
+ normalizedCompound: "sender@example.com:sender",
44
+ });
45
+ };
46
+
47
+ test("fifty concurrent merges all survive", async () => {
48
+ const addr = await address();
49
+ const writtenAt = new Map<string, Set<number>>();
50
+
51
+ await Promise.all(
52
+ Array.from({ length: CONCURRENT_CALLS }, (_, index) => {
53
+ const key = FLAG_KEYS[index % FLAG_KEYS.length];
54
+ const setAt = 1_000 + index;
55
+ const stamps = writtenAt.get(key) ?? new Set<number>();
56
+ stamps.add(setAt);
57
+ writtenAt.set(key, stamps);
58
+ return repo.mergeFlags(addr.accountConfigId, addr.addressId, {
59
+ [key]: { value: true, setAt },
60
+ });
61
+ }),
62
+ );
63
+
64
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
65
+ const flags: AddressFlags = merged.flags ?? {};
66
+ for (const key of FLAG_KEYS) {
67
+ const flag = flags[key];
68
+ assert.ok(flag, `${key} was lost by a concurrent merge`);
69
+ assert.ok(
70
+ writtenAt.get(key)?.has(flag.setAt),
71
+ `${key} holds a value no merge wrote`,
72
+ );
73
+ }
74
+
75
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
76
+ });
77
+
78
+ test("a concurrent merge does not resurrect a deleted flag", async () => {
79
+ const addr = await address();
80
+ await repo.mergeFlags(addr.accountConfigId, addr.addressId, {
81
+ muted: { value: true, setAt: 1 },
82
+ });
83
+
84
+ await Promise.all([
85
+ repo.mergeFlags(addr.accountConfigId, addr.addressId, { muted: null }),
86
+ repo.mergeFlags(addr.accountConfigId, addr.addressId, {
87
+ vip: { value: true, setAt: 2 },
88
+ }),
89
+ ]);
90
+
91
+ const merged = await repo.getAddress(addr.accountConfigId, addr.addressId);
92
+ assert.equal(merged.flags?.muted, undefined, "muted must stay deleted");
93
+ assert.equal(merged.flags?.vip?.value, true, "vip must survive the delete");
94
+
95
+ await repo.deleteAddress(addr.accountConfigId, addr.addressId);
96
+ });
97
+ });
@@ -148,7 +148,6 @@ describe("AddressRepo", () => {
148
148
  });
149
149
 
150
150
  const ea = await repo.createEnvelopeAddress({
151
- envelopeAddressId: envId,
152
151
  messageId,
153
152
  addressId,
154
153
  normalizedEmail: "test@example.com",
@@ -170,7 +169,6 @@ describe("AddressRepo", () => {
170
169
  test("upsertEnvelopeAddress is idempotent", async () => {
171
170
  const messageId = randomUUID();
172
171
  const input = {
173
- envelopeAddressId: envelopeAddressId(messageId, "to", 1),
174
172
  messageId,
175
173
  addressId: randomUUID(),
176
174
  normalizedEmail: "x@example.com",
@@ -179,15 +177,18 @@ describe("AddressRepo", () => {
179
177
  };
180
178
  const first = await repo.upsertEnvelopeAddress(input);
181
179
  const second = await repo.upsertEnvelopeAddress(input);
182
- assert.equal(first.envelopeAddressId, second.envelopeAddressId);
180
+ assert.equal(
181
+ first.envelopeAddressId,
182
+ envelopeAddressId(messageId, "to", 1),
183
+ );
184
+ assert.equal(second.envelopeAddressId, first.envelopeAddressId);
183
185
 
184
- await repo.deleteEnvelopeAddress(input.envelopeAddressId);
186
+ await repo.deleteEnvelopeAddress(first.envelopeAddressId);
185
187
  });
186
188
 
187
189
  test("deleteManyEnvelopeAddresses removes in batch", async () => {
188
190
  const messageId = randomUUID();
189
191
  const ea1 = {
190
- envelopeAddressId: envelopeAddressId(messageId, "from", 0),
191
192
  messageId,
192
193
  addressId: randomUUID(),
193
194
  normalizedEmail: "a@x.com",
@@ -196,22 +197,21 @@ describe("AddressRepo", () => {
196
197
  };
197
198
  const ea2 = {
198
199
  ...ea1,
199
- envelopeAddressId: envelopeAddressId(messageId, "to", 1),
200
200
  addressRole: "to" as const,
201
201
  addressOrder: 1,
202
202
  };
203
203
 
204
- await repo.createEnvelopeAddress(ea1);
205
- await repo.createEnvelopeAddress(ea2);
204
+ const created1 = await repo.createEnvelopeAddress(ea1);
205
+ const created2 = await repo.createEnvelopeAddress(ea2);
206
206
 
207
207
  await repo.deleteManyEnvelopeAddresses([
208
- ea1.envelopeAddressId,
209
- ea2.envelopeAddressId,
208
+ created1.envelopeAddressId,
209
+ created2.envelopeAddressId,
210
210
  ]);
211
211
 
212
212
  const results = await repo.getEnvelopeAddress([
213
- ea1.envelopeAddressId,
214
- ea2.envelopeAddressId,
213
+ created1.envelopeAddressId,
214
+ created2.envelopeAddressId,
215
215
  ]);
216
216
  assert.equal(results.length, 0);
217
217
  });
@@ -23,7 +23,7 @@ import {
23
23
  } from "drizzle-orm";
24
24
  import type { Db } from "../db.js";
25
25
  import { NotFoundError } from "../error.js";
26
- import { envelopeAddressId as deriveEnvelopeAddressId } from "../id.js";
26
+ import { envelopeAddressId } from "../id.js";
27
27
  import { decodeToken, resultList } from "../pagination.js";
28
28
  import {
29
29
  JUNK_ONLY_FLAG,
@@ -32,6 +32,7 @@ import {
32
32
  } from "../repair/junk-only-address.js";
33
33
  import { addressTable } from "../schema/i4-address.js";
34
34
  import { envelopeAddressTable } from "../schema/message-data.js";
35
+ import { runInTransaction } from "../tx.js";
35
36
  import {
36
37
  addressCorrespondence,
37
38
  addressListable,
@@ -49,6 +50,23 @@ type AddressUpdate = Partial<{
49
50
  | SQL;
50
51
  }>;
51
52
 
53
+ const MERGE_FLAGS_ATTEMPTS = 8;
54
+
55
+ /**
56
+ * The stored flags exactly as they sit in the column, so a merge can guard its
57
+ * write on the bytes it read rather than on a re-serialization of them.
58
+ */
59
+ const storedFlagsSql = sql<string>`cast(${addressTable.flags} as text)`;
60
+
61
+ /** A row harvested before the column existed carries `''`, not `'{}'`. */
62
+ const parseStoredFlags = (stored: string): AddressFlags =>
63
+ stored === "" ? {} : (JSON.parse(stored) as AddressFlags);
64
+
65
+ type MergeAttempt =
66
+ | { outcome: "merged"; address: AddressItem }
67
+ | { outcome: "missing" }
68
+ | { outcome: "contended" };
69
+
52
70
  /**
53
71
  * The stored `"<display name> <email>"` compound, folded in JavaScript exactly
54
72
  * as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
@@ -400,36 +418,77 @@ export class AddressRepo implements IAddressRepository {
400
418
  return rowToAddress(row);
401
419
  }
402
420
 
421
+ /**
422
+ * Read the flags, fold `merge` over them, and write the result back only if
423
+ * the column still holds the bytes the read returned. Read and write share
424
+ * one serialized unit, so the only writer that can slip between them is the
425
+ * junk-only reconcile, which runs its raw statement outside the write queue;
426
+ * that write fails the guard and the merge folds again over the winner
427
+ * instead of overwriting it. The unit never throws, so a reconcile statement
428
+ * landing inside its savepoint is never rolled back with it.
429
+ */
430
+ private async mergeStoredFlags(
431
+ accountConfigId: string,
432
+ addressId: string,
433
+ merge: (current: AddressFlags) => AddressFlags,
434
+ extra: AddressUpdate = {},
435
+ ): Promise<AddressItem> {
436
+ const key = and(
437
+ eq(addressTable.accountConfigId, accountConfigId),
438
+ eq(addressTable.addressId, addressId),
439
+ );
440
+ for (let attempt = 0; attempt < MERGE_FLAGS_ATTEMPTS; attempt++) {
441
+ const result = await runInTransaction(
442
+ this.db,
443
+ async (tx): Promise<MergeAttempt> => {
444
+ const [current] = await tx
445
+ .select({ stored: storedFlagsSql })
446
+ .from(addressTable)
447
+ .where(key);
448
+ if (!current) return { outcome: "missing" };
449
+ const [row] = await tx
450
+ .update(addressTable)
451
+ .set({
452
+ ...extra,
453
+ flags: merge(parseStoredFlags(current.stored)) as never,
454
+ updatedAt: Date.now(),
455
+ })
456
+ .where(and(key, eq(storedFlagsSql, current.stored)))
457
+ .returning();
458
+ return row
459
+ ? { outcome: "merged", address: rowToAddress(row) }
460
+ : { outcome: "contended" };
461
+ },
462
+ );
463
+ if (result.outcome === "merged") return result.address;
464
+ if (result.outcome === "missing")
465
+ throw new NotFoundError(`Address not found: ${addressId}`);
466
+ }
467
+ throw new Error(
468
+ `Address flags stayed contended after ${MERGE_FLAGS_ATTEMPTS} attempts: ${addressId}`,
469
+ );
470
+ }
471
+
403
472
  async mergeFlags(
404
473
  accountConfigId: string,
405
474
  addressId: string,
406
475
  patch: FlagsMergePatch,
407
476
  ): Promise<AddressItem> {
408
- const current = await this.getAddress(accountConfigId, addressId);
409
- const next: AddressFlags = { ...(current.flags ?? {}) };
410
- for (const [key, value] of Object.entries(patch) as [
411
- keyof AddressFlags,
412
- AddressFlags[keyof AddressFlags] | null | undefined,
413
- ][]) {
414
- if (value === undefined) continue;
415
- if (value === null) {
416
- delete next[key];
417
- continue;
477
+ return this.mergeStoredFlags(accountConfigId, addressId, (current) => {
478
+ const next: AddressFlags = { ...current };
479
+ for (const [key, value] of Object.entries(patch) as [
480
+ keyof AddressFlags,
481
+ AddressFlags[keyof AddressFlags] | null | undefined,
482
+ ][]) {
483
+ if (value === undefined) continue;
484
+ if (value === null) {
485
+ delete next[key];
486
+ continue;
487
+ }
488
+ (next[key] as AddressFlags[keyof AddressFlags]) = value;
418
489
  }
419
- (next[key] as AddressFlags[keyof AddressFlags]) = value;
420
- }
421
- const [row] = await this.db
422
- .update(addressTable)
423
- .set({ flags: next as never, updatedAt: Date.now() })
424
- .where(
425
- and(
426
- eq(addressTable.accountConfigId, accountConfigId),
427
- eq(addressTable.addressId, addressId),
428
- ),
429
- )
430
- .returning();
431
- if (!row) throw new NotFoundError(`Address not found: ${addressId}`);
432
- return rowToAddress(row);
490
+ return next;
491
+ });
433
492
  }
434
493
 
435
494
  async promoteWellknownByUser(
@@ -437,23 +496,10 @@ export class AddressRepo implements IAddressRepository {
437
496
  addressId: string,
438
497
  now: number,
439
498
  ): Promise<AddressItem> {
440
- const current = await this.getAddress(accountConfigId, addressId);
441
- const next: AddressFlags = {
442
- ...(current.flags ?? {}),
499
+ return this.mergeStoredFlags(accountConfigId, addressId, (current) => ({
500
+ ...current,
443
501
  wellknown: { value: true, setAt: now, setBy: "user-junk-rescue" },
444
- };
445
- const [row] = await this.db
446
- .update(addressTable)
447
- .set({ flags: next as never, updatedAt: Date.now() })
448
- .where(
449
- and(
450
- eq(addressTable.accountConfigId, accountConfigId),
451
- eq(addressTable.addressId, addressId),
452
- ),
453
- )
454
- .returning();
455
- if (!row) throw new NotFoundError(`Address not found: ${addressId}`);
456
- return rowToAddress(row);
502
+ }));
457
503
  }
458
504
 
459
505
  async demoteSenderTrust(
@@ -461,25 +507,12 @@ export class AddressRepo implements IAddressRepository {
461
507
  addressId: string,
462
508
  _now: number,
463
509
  ): Promise<AddressItem> {
464
- const current = await this.getAddress(accountConfigId, addressId);
465
- const { wellknown: _w, vip: _v, ...rest } = current.flags ?? {};
466
- const [row] = await this.db
467
- .update(addressTable)
468
- .set({
469
- flags: rest as never,
470
- inboundCount: 0,
471
- replyCount: 0,
472
- updatedAt: Date.now(),
473
- })
474
- .where(
475
- and(
476
- eq(addressTable.accountConfigId, accountConfigId),
477
- eq(addressTable.addressId, addressId),
478
- ),
479
- )
480
- .returning();
481
- if (!row) throw new NotFoundError(`Address not found: ${addressId}`);
482
- return rowToAddress(row);
510
+ return this.mergeStoredFlags(
511
+ accountConfigId,
512
+ addressId,
513
+ ({ wellknown: _w, vip: _v, ...rest }) => rest,
514
+ { inboundCount: 0, replyCount: 0 },
515
+ );
483
516
  }
484
517
 
485
518
  async deleteAddress(
@@ -711,15 +744,14 @@ export class AddressRepo implements IAddressRepository {
711
744
  input: CreateEnvelopeAddressInput,
712
745
  ): Promise<EnvelopeAddressItem> {
713
746
  const now = Date.now();
714
- const envelopeAddressId = deriveEnvelopeAddressId(
715
- input.messageId,
716
- input.addressRole,
717
- input.addressOrder,
718
- );
719
747
  const [row] = await this.db
720
748
  .insert(envelopeAddressTable)
721
749
  .values({
722
- envelopeAddressId,
750
+ envelopeAddressId: envelopeAddressId(
751
+ input.messageId,
752
+ input.addressRole,
753
+ input.addressOrder,
754
+ ),
723
755
  messageId: input.messageId,
724
756
  addressId: input.addressId,
725
757
  displayName: input.displayName,
@@ -737,7 +769,7 @@ export class AddressRepo implements IAddressRepository {
737
769
  input: CreateEnvelopeAddressInput,
738
770
  ): Promise<EnvelopeAddressItem> {
739
771
  const now = Date.now();
740
- const envelopeAddressId = deriveEnvelopeAddressId(
772
+ const rowId = envelopeAddressId(
741
773
  input.messageId,
742
774
  input.addressRole,
743
775
  input.addressOrder,
@@ -745,7 +777,7 @@ export class AddressRepo implements IAddressRepository {
745
777
  const [row] = await this.db
746
778
  .insert(envelopeAddressTable)
747
779
  .values({
748
- envelopeAddressId,
780
+ envelopeAddressId: rowId,
749
781
  messageId: input.messageId,
750
782
  addressId: input.addressId,
751
783
  displayName: input.displayName,
@@ -758,7 +790,7 @@ export class AddressRepo implements IAddressRepository {
758
790
  .onConflictDoNothing()
759
791
  .returning();
760
792
  if (!row) {
761
- return this.getEnvelopeAddress(envelopeAddressId);
793
+ return this.getEnvelopeAddress(rowId);
762
794
  }
763
795
  return rowToEnvelopeAddress(row);
764
796
  }