@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
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
209
|
-
|
|
208
|
+
created1.envelopeAddressId,
|
|
209
|
+
created2.envelopeAddressId,
|
|
210
210
|
]);
|
|
211
211
|
|
|
212
212
|
const results = await repo.getEnvelopeAddress([
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
created1.envelopeAddressId,
|
|
214
|
+
created2.envelopeAddressId,
|
|
215
215
|
]);
|
|
216
216
|
assert.equal(results.length, 0);
|
|
217
217
|
});
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
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(
|
|
793
|
+
return this.getEnvelopeAddress(rowId);
|
|
762
794
|
}
|
|
763
795
|
return rowToEnvelopeAddress(row);
|
|
764
796
|
}
|