@remit/mailbox-service 0.0.10 → 0.0.11

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.
@@ -0,0 +1,418 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ advanceChangeCursor,
5
+ advanceUidWatermarks,
6
+ type ChangeCursor,
7
+ dropAppliedPrefix,
8
+ formatChangeCursor,
9
+ orderByModseq,
10
+ parseChangeCursor,
11
+ parseModseq,
12
+ } from "./sync-watermarks.js";
13
+ import type { ImapMessage } from "./types.js";
14
+
15
+ const message = (uid: number, modseq?: string): ImapMessage => ({
16
+ uid,
17
+ seq: uid,
18
+ flags: [],
19
+ internalDate: new Date(0),
20
+ size: 0,
21
+ ...(modseq !== undefined ? { modseq } : {}),
22
+ });
23
+
24
+ /** `count` messages sharing one mod-sequence, as a single STORE produces. */
25
+ const tieGroup = (
26
+ count: number,
27
+ modseq: string,
28
+ firstUid: number,
29
+ ): ImapMessage[] =>
30
+ Array.from({ length: count }, (_, i) => message(firstUid + i, modseq));
31
+
32
+ describe("parseModseq", () => {
33
+ it("parses a value beyond 2^53 losslessly", () => {
34
+ assert.equal(parseModseq("18446744073709551615"), 18446744073709551615n);
35
+ });
36
+
37
+ it("treats an absent or zero watermark as no watermark", () => {
38
+ assert.equal(parseModseq(undefined), 0n);
39
+ assert.equal(parseModseq(""), 0n);
40
+ assert.equal(parseModseq("0"), 0n);
41
+ });
42
+ });
43
+
44
+ describe("orderByModseq", () => {
45
+ it("orders oldest change first so a partial round is resumable", () => {
46
+ const ordered = orderByModseq([
47
+ message(3, "30"),
48
+ message(1, "10"),
49
+ message(2, "20"),
50
+ ]);
51
+
52
+ assert.deepEqual(
53
+ ordered.map((m) => m.uid),
54
+ [1, 2, 3],
55
+ );
56
+ });
57
+
58
+ it("breaks ties on UID and compares beyond 2^53", () => {
59
+ const ordered = orderByModseq([
60
+ message(9, "9007199254740993"),
61
+ message(4, "9007199254740992"),
62
+ message(2, "9007199254740992"),
63
+ ]);
64
+
65
+ assert.deepEqual(
66
+ ordered.map((m) => m.uid),
67
+ [2, 4, 9],
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("parseChangeCursor / formatChangeCursor", () => {
73
+ it("reads a plain value written before the sub-position existed", () => {
74
+ assert.deepEqual(parseChangeCursor("900"), {
75
+ modseq: 900n,
76
+ group: 0n,
77
+ uid: 0,
78
+ });
79
+ });
80
+
81
+ it("round-trips a clean boundary as plain digits", () => {
82
+ assert.equal(
83
+ formatChangeCursor({ modseq: 900n, group: 0n, uid: 0 }),
84
+ "900",
85
+ );
86
+ });
87
+
88
+ it("round-trips a position inside a group, implying the fetch point", () => {
89
+ assert.equal(
90
+ formatChangeCursor({ modseq: 199n, group: 200n, uid: 500 }),
91
+ "200:500",
92
+ );
93
+ assert.deepEqual(parseChangeCursor("200:500"), {
94
+ modseq: 199n,
95
+ group: 200n,
96
+ uid: 500,
97
+ });
98
+ });
99
+
100
+ it("stays inside the field's 32 characters at full 64-bit width", () => {
101
+ const widest = formatChangeCursor({
102
+ modseq: 18446744073709551614n,
103
+ group: 18446744073709551615n,
104
+ uid: 4294967295,
105
+ });
106
+
107
+ assert.equal(widest, "18446744073709551615:4294967295");
108
+ assert.ok(widest.length <= 32);
109
+ assert.deepEqual(parseChangeCursor(widest), {
110
+ modseq: 18446744073709551614n,
111
+ group: 18446744073709551615n,
112
+ uid: 4294967295,
113
+ });
114
+ });
115
+
116
+ it("treats an absent cursor as no cursor", () => {
117
+ assert.deepEqual(parseChangeCursor(undefined), {
118
+ modseq: 0n,
119
+ group: 0n,
120
+ uid: 0,
121
+ });
122
+ assert.deepEqual(parseChangeCursor("0"), {
123
+ modseq: 0n,
124
+ group: 0n,
125
+ uid: 0,
126
+ });
127
+ });
128
+
129
+ it("degrades a malformed cursor to enumeration instead of throwing", () => {
130
+ // A throw here would take the mailbox out of sync permanently, with no
131
+ // path left to read the value and repair it.
132
+ for (const raw of ["abc", "500:abc", "abc:1", "", ":", "1:2:3"]) {
133
+ assert.doesNotThrow(() => parseChangeCursor(raw));
134
+ }
135
+ assert.equal(parseChangeCursor("abc").modseq, 0n);
136
+ assert.equal(parseChangeCursor("500:abc").modseq, 500n);
137
+ });
138
+
139
+ it("survives a numeric column value", () => {
140
+ // SQLite hands back a number whatever the declared column type.
141
+ assert.deepEqual(parseChangeCursor(900 as unknown as string), {
142
+ modseq: 900n,
143
+ group: 0n,
144
+ uid: 0,
145
+ });
146
+ });
147
+ });
148
+
149
+ describe("dropAppliedPrefix", () => {
150
+ it("passes everything through on a clean boundary", () => {
151
+ const ordered = tieGroup(3, "900", 10);
152
+
153
+ assert.equal(
154
+ dropAppliedPrefix(ordered, { modseq: 800n, group: 0n, uid: 0 }).length,
155
+ 3,
156
+ );
157
+ });
158
+
159
+ it("drops the members of the recorded group already applied", () => {
160
+ const ordered = [...tieGroup(4, "200", 500), message(50, "300")];
161
+
162
+ const remaining = dropAppliedPrefix(ordered, {
163
+ modseq: 199n,
164
+ group: 200n,
165
+ uid: 500,
166
+ });
167
+
168
+ assert.deepEqual(
169
+ remaining.map((m) => m.uid),
170
+ [501, 502, 503, 50],
171
+ );
172
+ });
173
+
174
+ it("skips nothing when the recorded group has vanished from the result", () => {
175
+ // The group's remainder was expunged, or re-modified onto a higher
176
+ // mod-sequence. Inferring the in-progress group from the lowest one
177
+ // returned would drop the two leading messages of an unrelated group.
178
+ const ordered = [
179
+ message(10, "300"),
180
+ message(20, "300"),
181
+ message(600, "300"),
182
+ ];
183
+
184
+ const remaining = dropAppliedPrefix(ordered, {
185
+ modseq: 199n,
186
+ group: 200n,
187
+ uid: 500,
188
+ });
189
+
190
+ assert.deepEqual(
191
+ remaining.map((m) => m.uid),
192
+ [10, 20, 600],
193
+ );
194
+ });
195
+ });
196
+
197
+ describe("advanceChangeCursor", () => {
198
+ const noFailures = new Set<number>();
199
+ const boundary = (modseq: bigint): ChangeCursor => ({
200
+ modseq,
201
+ group: 0n,
202
+ uid: 0,
203
+ });
204
+
205
+ it("jumps to the server HIGHESTMODSEQ when the whole set was applied", () => {
206
+ const ordered = [message(1, "11"), message(2, "12")];
207
+
208
+ const result = advanceChangeCursor({
209
+ cursor: boundary(10n),
210
+ serverModseq: 20n,
211
+ ordered,
212
+ batch: ordered,
213
+ failedUids: noFailures,
214
+ });
215
+
216
+ assert.deepEqual(result, { cursor: boundary(20n), hasMore: false });
217
+ });
218
+
219
+ it("records which group it stopped inside, and where", () => {
220
+ // 60 messages marked read by one STORE; the round applies 50.
221
+ const ordered = tieGroup(60, "900", 10);
222
+
223
+ const result = advanceChangeCursor({
224
+ cursor: boundary(800n),
225
+ serverModseq: 900n,
226
+ ordered,
227
+ batch: ordered.slice(0, 50),
228
+ failedUids: noFailures,
229
+ });
230
+
231
+ assert.deepEqual(result, {
232
+ cursor: { modseq: 899n, group: 900n, uid: 59 },
233
+ hasMore: true,
234
+ });
235
+ });
236
+
237
+ it("resumes a part-applied group and closes it on the boundary", () => {
238
+ const ordered = tieGroup(10, "900", 60);
239
+
240
+ const result = advanceChangeCursor({
241
+ cursor: { modseq: 899n, group: 900n, uid: 59 },
242
+ serverModseq: 900n,
243
+ ordered,
244
+ batch: ordered,
245
+ failedUids: noFailures,
246
+ });
247
+
248
+ assert.deepEqual(result, { cursor: boundary(900n), hasMore: false });
249
+ });
250
+
251
+ it("stops on the boundary when the next change starts a new group", () => {
252
+ const ordered = [...tieGroup(2, "900", 10), ...tieGroup(2, "950", 20)];
253
+
254
+ const result = advanceChangeCursor({
255
+ cursor: boundary(800n),
256
+ serverModseq: 999n,
257
+ ordered,
258
+ batch: ordered.slice(0, 2),
259
+ failedUids: noFailures,
260
+ });
261
+
262
+ assert.deepEqual(result, { cursor: boundary(900n), hasMore: true });
263
+ });
264
+
265
+ it("stops below a failure inside a group", () => {
266
+ const ordered = tieGroup(4, "900", 10);
267
+
268
+ const result = advanceChangeCursor({
269
+ cursor: boundary(800n),
270
+ serverModseq: 900n,
271
+ ordered,
272
+ batch: ordered,
273
+ failedUids: new Set([12]),
274
+ });
275
+
276
+ assert.deepEqual(result.cursor, { modseq: 899n, group: 900n, uid: 11 });
277
+ });
278
+
279
+ it("does not move when the very first change fails", () => {
280
+ const ordered = tieGroup(2, "900", 10);
281
+
282
+ const result = advanceChangeCursor({
283
+ cursor: boundary(800n),
284
+ serverModseq: 900n,
285
+ ordered,
286
+ batch: ordered,
287
+ failedUids: new Set([10]),
288
+ });
289
+
290
+ assert.deepEqual(result.cursor, boundary(800n));
291
+ });
292
+
293
+ it("keeps every completed group when a later one fails part-way", () => {
294
+ const ordered = [...tieGroup(2, "810", 1), ...tieGroup(3, "900", 10)];
295
+
296
+ const result = advanceChangeCursor({
297
+ cursor: boundary(800n),
298
+ serverModseq: 999n,
299
+ ordered,
300
+ batch: ordered,
301
+ failedUids: new Set([11]),
302
+ });
303
+
304
+ // 810 is complete and 900 is part-applied, so the fetch point sits just
305
+ // below 900 and the group is named.
306
+ assert.deepEqual(result.cursor, { modseq: 899n, group: 900n, uid: 10 });
307
+ });
308
+
309
+ it("floors the fetch point at zero when a message carries no MODSEQ", () => {
310
+ const ordered = [message(1), message(2)];
311
+
312
+ const result = advanceChangeCursor({
313
+ cursor: boundary(0n),
314
+ serverModseq: 0n,
315
+ ordered,
316
+ batch: ordered.slice(0, 1),
317
+ failedUids: noFailures,
318
+ });
319
+
320
+ assert.equal(result.cursor.modseq >= 0n, true);
321
+ assert.equal(formatChangeCursor(result.cursor).startsWith("-"), false);
322
+ });
323
+
324
+ it("never moves backwards when the server reports a lower value", () => {
325
+ const result = advanceChangeCursor({
326
+ cursor: boundary(50n),
327
+ serverModseq: 20n,
328
+ ordered: [],
329
+ batch: [],
330
+ failedUids: noFailures,
331
+ });
332
+
333
+ assert.deepEqual(result.cursor, boundary(50n));
334
+ });
335
+ });
336
+
337
+ describe("advanceUidWatermarks", () => {
338
+ const noFailures = new Set<number>();
339
+
340
+ it("advances the forward watermark over a clean batch of new mail", () => {
341
+ const result = advanceUidWatermarks({
342
+ batchUids: [21, 22, 23],
343
+ failedUids: noFailures,
344
+ lastSyncUid: 5,
345
+ highWaterMarkUid: 20,
346
+ });
347
+
348
+ assert.equal(result.highWaterMarkUid, 23);
349
+ assert.equal(result.lastSyncUid, 5);
350
+ });
351
+
352
+ it("stops the forward watermark below a failed UID rather than jumping it", () => {
353
+ const result = advanceUidWatermarks({
354
+ batchUids: [21, 22, 23, 24, 25],
355
+ failedUids: new Set([23]),
356
+ lastSyncUid: 5,
357
+ highWaterMarkUid: 20,
358
+ });
359
+
360
+ assert.equal(result.highWaterMarkUid, 22);
361
+ });
362
+
363
+ it("keeps a failed backfill UID inside the backfill window", () => {
364
+ const result = advanceUidWatermarks({
365
+ batchUids: [5, 6, 7],
366
+ failedUids: new Set([6]),
367
+ lastSyncUid: 10,
368
+ highWaterMarkUid: 20,
369
+ });
370
+
371
+ assert.equal(result.lastSyncUid, 7);
372
+ });
373
+
374
+ it("lowers the backfill floor to the lowest UID of a clean batch", () => {
375
+ const result = advanceUidWatermarks({
376
+ batchUids: [5, 6, 7],
377
+ failedUids: noFailures,
378
+ lastSyncUid: 10,
379
+ highWaterMarkUid: 20,
380
+ });
381
+
382
+ assert.equal(result.lastSyncUid, 5);
383
+ });
384
+
385
+ it("lets a failure constrain only its own region", () => {
386
+ const result = advanceUidWatermarks({
387
+ batchUids: [5, 6, 21, 22],
388
+ failedUids: new Set([6]),
389
+ lastSyncUid: 10,
390
+ highWaterMarkUid: 20,
391
+ });
392
+
393
+ assert.equal(result.highWaterMarkUid, 22);
394
+ assert.equal(result.lastSyncUid, 7);
395
+ });
396
+
397
+ it("sets both watermarks from a fresh sync", () => {
398
+ const result = advanceUidWatermarks({
399
+ batchUids: [1, 2, 3],
400
+ failedUids: noFailures,
401
+ lastSyncUid: 0,
402
+ highWaterMarkUid: 0,
403
+ });
404
+
405
+ assert.deepEqual(result, { lastSyncUid: 1, highWaterMarkUid: 3 });
406
+ });
407
+
408
+ it("never moves a watermark backwards", () => {
409
+ const result = advanceUidWatermarks({
410
+ batchUids: [21],
411
+ failedUids: new Set([21]),
412
+ lastSyncUid: 5,
413
+ highWaterMarkUid: 20,
414
+ });
415
+
416
+ assert.deepEqual(result, { lastSyncUid: 5, highWaterMarkUid: 20 });
417
+ });
418
+ });