@bumail/queue 0.11.0 → 0.12.0
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/README.md +25 -0
- package/dist/conformance/claims.d.ts +4 -0
- package/dist/conformance/claims.d.ts.map +1 -0
- package/dist/conformance/complete-dsns.d.ts +4 -0
- package/dist/conformance/complete-dsns.d.ts.map +1 -0
- package/dist/conformance/concurrency.d.ts +4 -0
- package/dist/conformance/concurrency.d.ts.map +1 -0
- package/dist/conformance/describe-queue-store.d.ts +11 -0
- package/dist/conformance/describe-queue-store.d.ts.map +1 -0
- package/dist/conformance/doubt.d.ts +5 -0
- package/dist/conformance/doubt.d.ts.map +1 -0
- package/dist/conformance/dsn.d.ts +4 -0
- package/dist/conformance/dsn.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +3 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +820 -0
- package/dist/conformance/index.js.map +18 -0
- package/dist/conformance/items.d.ts +4 -0
- package/dist/conformance/items.d.ts.map +1 -0
- package/dist/conformance/outcomes.d.ts +4 -0
- package/dist/conformance/outcomes.d.ts.map +1 -0
- package/dist/conformance/setup.d.ts +40 -0
- package/dist/conformance/setup.d.ts.map +1 -0
- package/docs/guide.md +84 -2
- package/docs/roadmap.md +10 -2
- package/docs/troubleshooting.md +42 -0
- package/package.json +8 -2
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// src/conformance/describe-queue-store.ts
|
|
2
|
+
import { afterEach, describe as describe5 } from "bun:test";
|
|
3
|
+
|
|
4
|
+
// src/conformance/claims.ts
|
|
5
|
+
import { describe, expect as expect2, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
// src/conformance/setup.ts
|
|
8
|
+
import { expect } from "bun:test";
|
|
9
|
+
var bytes = (text) => new TextEncoder().encode(text);
|
|
10
|
+
var T0 = Date.UTC(2026, 9, 1, 12);
|
|
11
|
+
var MINUTE = 60000;
|
|
12
|
+
var lease = { leaseMs: 10 * MINUTE };
|
|
13
|
+
var result = (overrides = {}) => ({
|
|
14
|
+
now: T0 + MINUTE,
|
|
15
|
+
recipients: [],
|
|
16
|
+
nextAttemptAt: T0 + 30 * MINUTE,
|
|
17
|
+
attempts: 1,
|
|
18
|
+
delayNotified: false,
|
|
19
|
+
...overrides
|
|
20
|
+
});
|
|
21
|
+
function entry(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
from: "mary@example.net",
|
|
24
|
+
to: ["joe@example.com", "ann@example.org"],
|
|
25
|
+
message: bytes(`Subject: Hi\r
|
|
26
|
+
\r
|
|
27
|
+
Hello\r
|
|
28
|
+
`),
|
|
29
|
+
createdAt: T0,
|
|
30
|
+
...overrides
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
async function rejects(promise, code) {
|
|
34
|
+
await expect(promise).rejects.toMatchObject({ name: "QueueError", code });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/conformance/claims.ts
|
|
38
|
+
var lease2 = { leaseMs: 10 * MINUTE };
|
|
39
|
+
var result2 = (overrides = {}) => ({
|
|
40
|
+
now: T0 + MINUTE,
|
|
41
|
+
recipients: [],
|
|
42
|
+
nextAttemptAt: T0 + 30 * MINUTE,
|
|
43
|
+
attempts: 1,
|
|
44
|
+
delayNotified: false,
|
|
45
|
+
...overrides
|
|
46
|
+
});
|
|
47
|
+
function describeClaims(factories) {
|
|
48
|
+
describe("claims and leases", () => {
|
|
49
|
+
claiming(factories);
|
|
50
|
+
leasing(factories);
|
|
51
|
+
rescheduling(factories);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function claiming({ create }) {
|
|
55
|
+
test("claim takes a due item with a lease; nothing else is due", async () => {
|
|
56
|
+
const store = await create();
|
|
57
|
+
const item = await store.add(entry());
|
|
58
|
+
const claimed = await store.claim({ owner: "w1", now: T0, ...lease2 });
|
|
59
|
+
expect2(claimed?.id).toBe(item.id);
|
|
60
|
+
expect2(claimed?.lease).toEqual({
|
|
61
|
+
owner: "w1",
|
|
62
|
+
expiresAt: T0 + 10 * MINUTE
|
|
63
|
+
});
|
|
64
|
+
expect2((await store.get(item.id))?.lease?.owner).toBe("w1");
|
|
65
|
+
expect2(await store.claim({ owner: "w2", now: T0, ...lease2 })).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
test("an item not yet due is not claimed", async () => {
|
|
68
|
+
const store = await create();
|
|
69
|
+
await store.add(entry({ createdAt: T0 + MINUTE }));
|
|
70
|
+
expect2(await store.claim({ owner: "w1", now: T0, ...lease2 })).toBeUndefined();
|
|
71
|
+
expect2(await store.claim({ owner: "w1", now: T0 + MINUTE, ...lease2 })).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
test("the earliest due is claimed first, then the oldest", async () => {
|
|
74
|
+
const store = await create();
|
|
75
|
+
const late = await store.add(entry({ createdAt: T0 + MINUTE }));
|
|
76
|
+
const first = await store.add(entry());
|
|
77
|
+
const second = await store.add(entry());
|
|
78
|
+
const now = T0 + MINUTE;
|
|
79
|
+
const order = [];
|
|
80
|
+
for (let i = 0;i < 3; i++) {
|
|
81
|
+
order.push((await store.claim({ owner: "w", now, ...lease2 }))?.id);
|
|
82
|
+
}
|
|
83
|
+
expect2(order).toEqual([first.id, second.id, late.id]);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function leasing({ create }) {
|
|
87
|
+
test("an expired lease is claimed again, and its old owner has lost it", async () => {
|
|
88
|
+
const store = await create();
|
|
89
|
+
const item = await store.add(entry());
|
|
90
|
+
await store.claim({ owner: "crashed", now: T0, leaseMs: MINUTE });
|
|
91
|
+
expect2(await store.claim({ owner: "w2", now: T0 + MINUTE - 1, ...lease2 })).toBeUndefined();
|
|
92
|
+
const again = await store.claim({
|
|
93
|
+
owner: "w2",
|
|
94
|
+
now: T0 + MINUTE,
|
|
95
|
+
...lease2
|
|
96
|
+
});
|
|
97
|
+
expect2(again?.id).toBe(item.id);
|
|
98
|
+
expect2(again?.lease?.owner).toBe("w2");
|
|
99
|
+
expect2(await store.renew(item.id, "crashed", T0 + 5 * MINUTE)).toBe(false);
|
|
100
|
+
expect2(await store.complete(item.id, "crashed", result2(), [])).toBeUndefined();
|
|
101
|
+
expect2(await store.reschedule(item.id, T0, "crashed")).toBe(false);
|
|
102
|
+
expect2((await store.get(item.id))?.lease?.owner).toBe("w2");
|
|
103
|
+
});
|
|
104
|
+
test("renew moves the expiry while the owner holds the lease", async () => {
|
|
105
|
+
const store = await create();
|
|
106
|
+
const item = await store.add(entry());
|
|
107
|
+
await store.claim({ owner: "w1", now: T0, leaseMs: MINUTE });
|
|
108
|
+
expect2(await store.renew(item.id, "w1", T0 + 3 * MINUTE)).toBe(true);
|
|
109
|
+
expect2(await store.claim({ owner: "w2", now: T0 + 2 * MINUTE, ...lease2 })).toBeUndefined();
|
|
110
|
+
expect2(await store.renew("nope", "w1", T0)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function rescheduling({ create }) {
|
|
114
|
+
test("reschedule with the owner gives the item back, due at the new time", async () => {
|
|
115
|
+
const store = await create();
|
|
116
|
+
const item = await store.add(entry());
|
|
117
|
+
await store.claim({ owner: "w1", now: T0, ...lease2 });
|
|
118
|
+
expect2(await store.reschedule(item.id, T0 + MINUTE, "w2")).toBe(false);
|
|
119
|
+
expect2(await store.reschedule(item.id, T0 + MINUTE, "w1")).toBe(true);
|
|
120
|
+
const stored = await store.get(item.id);
|
|
121
|
+
expect2(stored?.lease).toBeUndefined();
|
|
122
|
+
expect2(stored?.nextAttemptAt).toBe(T0 + MINUTE);
|
|
123
|
+
expect2(stored?.attempts).toBe(0);
|
|
124
|
+
});
|
|
125
|
+
test("reschedule without an owner moves the time and leaves the lease alone", async () => {
|
|
126
|
+
const store = await create();
|
|
127
|
+
const item = await store.add(entry());
|
|
128
|
+
await store.claim({ owner: "w1", now: T0, ...lease2 });
|
|
129
|
+
await store.complete(item.id, "w1", result2(), []);
|
|
130
|
+
expect2(await store.reschedule(item.id, T0 + MINUTE)).toBe(true);
|
|
131
|
+
expect2((await store.claim({ owner: "w2", now: T0 + MINUTE, ...lease2 }))?.id).toBe(item.id);
|
|
132
|
+
expect2(await store.reschedule(item.id, T0)).toBe(true);
|
|
133
|
+
expect2((await store.get(item.id))?.lease?.owner).toBe("w2");
|
|
134
|
+
});
|
|
135
|
+
test("claim refuses a bad request", async () => {
|
|
136
|
+
const store = await create();
|
|
137
|
+
await rejects(store.claim({ owner: "", now: T0, ...lease2 }), "INVALID");
|
|
138
|
+
await rejects(store.claim({ owner: "w", now: T0, leaseMs: 0 }), "INVALID");
|
|
139
|
+
await rejects(store.claim({ owner: "w", now: Number.NaN, ...lease2 }), "INVALID");
|
|
140
|
+
await rejects(store.reschedule("nope", T0, ""), "INVALID");
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/conformance/concurrency.ts
|
|
145
|
+
import { describe as describe2, expect as expect3, test as test2 } from "bun:test";
|
|
146
|
+
var delivered = (now) => ({
|
|
147
|
+
now,
|
|
148
|
+
recipients: [
|
|
149
|
+
{ address: "joe@example.com", status: "delivered" },
|
|
150
|
+
{ address: "ann@example.org", status: "delivered" }
|
|
151
|
+
],
|
|
152
|
+
nextAttemptAt: now,
|
|
153
|
+
attempts: 1,
|
|
154
|
+
delayNotified: false
|
|
155
|
+
});
|
|
156
|
+
async function work(store, owner, now) {
|
|
157
|
+
const taken = [];
|
|
158
|
+
for (;; ) {
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 2));
|
|
160
|
+
const item = await store.claim({ owner, now, leaseMs: 10 * MINUTE });
|
|
161
|
+
if (!item)
|
|
162
|
+
return taken;
|
|
163
|
+
taken.push(item.id);
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 2));
|
|
165
|
+
const done = await store.complete(item.id, owner, delivered(now), []);
|
|
166
|
+
expect3(done).toBeDefined();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function describeConcurrency({ create, share }) {
|
|
170
|
+
const shared = share ? test2 : test2.skip;
|
|
171
|
+
const note = share ? "" : " (skipped: harness has no share)";
|
|
172
|
+
describe2("several workers", () => {
|
|
173
|
+
shared(`many claimers race: every item is delivered once${note}`, async () => {
|
|
174
|
+
const store = await create();
|
|
175
|
+
const ids = new Set;
|
|
176
|
+
for (let i = 0;i < 60; i++)
|
|
177
|
+
ids.add((await store.add(entry())).id);
|
|
178
|
+
const workers = Array.from({ length: 8 }, (_, i) => work(share?.(store) ?? store, `w${i}`, T0));
|
|
179
|
+
const taken = (await Promise.all(workers)).flat();
|
|
180
|
+
expect3(taken.length).toBe(60);
|
|
181
|
+
expect3(new Set(taken)).toEqual(ids);
|
|
182
|
+
expect3(await store.count()).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
shared(`a crashed worker’s items are claimed again once its leases expire${note}`, async () => {
|
|
185
|
+
const store = await create();
|
|
186
|
+
for (let i = 0;i < 5; i++)
|
|
187
|
+
await store.add(entry());
|
|
188
|
+
const crashed = [];
|
|
189
|
+
for (let i = 0;i < 5; i++) {
|
|
190
|
+
const item = await store.claim({
|
|
191
|
+
owner: "crashed",
|
|
192
|
+
now: T0,
|
|
193
|
+
leaseMs: MINUTE
|
|
194
|
+
});
|
|
195
|
+
if (item)
|
|
196
|
+
crashed.push(item.id);
|
|
197
|
+
}
|
|
198
|
+
expect3(crashed.length).toBe(5);
|
|
199
|
+
const other = share?.(store) ?? store;
|
|
200
|
+
expect3(await work(other, "w2", T0 + MINUTE - 1)).toEqual([]);
|
|
201
|
+
const recovered = await work(other, "w2", T0 + MINUTE);
|
|
202
|
+
expect3(new Set(recovered)).toEqual(new Set(crashed));
|
|
203
|
+
for (const id of crashed) {
|
|
204
|
+
expect3(await store.complete(id, "crashed", delivered(T0), [])).toBeUndefined();
|
|
205
|
+
}
|
|
206
|
+
expect3(await store.count()).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/conformance/items.ts
|
|
212
|
+
import { describe as describe3, expect as expect5, test as test4 } from "bun:test";
|
|
213
|
+
|
|
214
|
+
// src/conformance/dsn.ts
|
|
215
|
+
import { expect as expect4, test as test3 } from "bun:test";
|
|
216
|
+
function dsnParameters({ create }) {
|
|
217
|
+
const asked = {
|
|
218
|
+
ret: "HDRS",
|
|
219
|
+
envid: "QQ314159 + x=",
|
|
220
|
+
recipients: [
|
|
221
|
+
{
|
|
222
|
+
address: "joe@example.com",
|
|
223
|
+
notify: ["SUCCESS", "DELAY"],
|
|
224
|
+
orcpt: { type: "rfc822", address: "Joe.Original@example.com" }
|
|
225
|
+
},
|
|
226
|
+
{ address: "ann@example.org", notify: "NEVER" }
|
|
227
|
+
]
|
|
228
|
+
};
|
|
229
|
+
test3("RFC 3461 §4: ret, envid, notify and orcpt are kept by add, get, list and claim", async () => {
|
|
230
|
+
const store = await create();
|
|
231
|
+
const item = await store.add(entry(asked));
|
|
232
|
+
const kept = {
|
|
233
|
+
ret: "HDRS",
|
|
234
|
+
envid: "QQ314159 + x=",
|
|
235
|
+
recipients: [
|
|
236
|
+
{
|
|
237
|
+
address: "joe@example.com",
|
|
238
|
+
notify: ["SUCCESS", "DELAY"],
|
|
239
|
+
orcpt: { type: "rfc822", address: "Joe.Original@example.com" }
|
|
240
|
+
},
|
|
241
|
+
{ address: "ann@example.org", notify: "NEVER" }
|
|
242
|
+
]
|
|
243
|
+
};
|
|
244
|
+
expect4(item).toMatchObject(kept);
|
|
245
|
+
expect4(await store.get(item.id)).toEqual(item);
|
|
246
|
+
expect4((await store.list())[0]).toEqual(item);
|
|
247
|
+
expect4(await store.claim({ owner: "w1", now: T0, ...lease })).toMatchObject(kept);
|
|
248
|
+
});
|
|
249
|
+
test3("complete keeps them: the notify and orcpt of a deferred recipient, and the item level", async () => {
|
|
250
|
+
const store = await create();
|
|
251
|
+
const item = await store.add(entry(asked));
|
|
252
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
253
|
+
const done = await store.complete(item.id, "w1", result({
|
|
254
|
+
recipients: [
|
|
255
|
+
{
|
|
256
|
+
address: "joe@example.com",
|
|
257
|
+
status: "deferred",
|
|
258
|
+
reply: { text: "try later" }
|
|
259
|
+
}
|
|
260
|
+
]
|
|
261
|
+
}), []);
|
|
262
|
+
expect4(done?.item.recipients[0]).toMatchObject({
|
|
263
|
+
notify: ["SUCCESS", "DELAY"],
|
|
264
|
+
orcpt: { type: "rfc822", address: "Joe.Original@example.com" }
|
|
265
|
+
});
|
|
266
|
+
const again = await store.get(item.id);
|
|
267
|
+
expect4(again?.ret).toBe("HDRS");
|
|
268
|
+
expect4(again?.envid).toBe("QQ314159 + x=");
|
|
269
|
+
expect4(again?.recipients[1]?.notify).toBe("NEVER");
|
|
270
|
+
});
|
|
271
|
+
test3("an item added with none carries none: the fields are absent, as before", async () => {
|
|
272
|
+
const store = await create();
|
|
273
|
+
const item = await store.add(entry());
|
|
274
|
+
const read = await store.get(item.id);
|
|
275
|
+
for (const key of ["ret", "envid"]) {
|
|
276
|
+
expect4(read).not.toHaveProperty(key);
|
|
277
|
+
}
|
|
278
|
+
for (const r of read?.recipients ?? []) {
|
|
279
|
+
expect4(r).not.toHaveProperty("notify");
|
|
280
|
+
expect4(r).not.toHaveProperty("orcpt");
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
test3("RFC 6533: 50 emoji in a utf-8 ORCPT are within 500 characters, as the server counts them", async () => {
|
|
284
|
+
const store = await create();
|
|
285
|
+
const orcpt = { type: "utf-8", address: `${"\uD83D\uDE00".repeat(50)}@x` };
|
|
286
|
+
const item = await store.add(entry({ recipients: [{ address: "joe@example.com", orcpt }] }));
|
|
287
|
+
expect4(item.recipients[0]?.orcpt).toEqual(orcpt);
|
|
288
|
+
await expect4(store.add(entry({
|
|
289
|
+
recipients: [
|
|
290
|
+
{
|
|
291
|
+
address: "joe@example.com",
|
|
292
|
+
orcpt: { type: "rfc822", address: orcpt.address }
|
|
293
|
+
}
|
|
294
|
+
]
|
|
295
|
+
}))).rejects.toMatchObject({ code: "INVALID" });
|
|
296
|
+
});
|
|
297
|
+
test3("RFC 6533: a utf-8 ORCPT of 500 characters (82 escapes) is kept, 506 is INVALID", async () => {
|
|
298
|
+
const store = await create();
|
|
299
|
+
const orcpt = (n) => ({
|
|
300
|
+
type: "utf-8",
|
|
301
|
+
address: `${"+".repeat(n)}@x`
|
|
302
|
+
});
|
|
303
|
+
const item = await store.add(entry({ recipients: [{ address: "joe@example.com", orcpt: orcpt(82) }] }));
|
|
304
|
+
expect4(item.recipients[0]?.orcpt).toEqual(orcpt(82));
|
|
305
|
+
await rejects(store.add(entry({
|
|
306
|
+
recipients: [{ address: "joe@example.com", orcpt: orcpt(83) }]
|
|
307
|
+
})), "INVALID");
|
|
308
|
+
});
|
|
309
|
+
test3("what is not a parameter is INVALID, the same for every store", async () => {
|
|
310
|
+
const store = await create();
|
|
311
|
+
const bad = [
|
|
312
|
+
{ ret: "ALL" },
|
|
313
|
+
{ envid: "" },
|
|
314
|
+
{ envid: `a\r
|
|
315
|
+
b` },
|
|
316
|
+
{ envid: "x".repeat(101) },
|
|
317
|
+
{ recipients: [{ address: "zed@example.com", notify: "NEVER" }] },
|
|
318
|
+
{ recipients: [{ address: "joe@example.com", notify: "SOMETIMES" }] },
|
|
319
|
+
{ recipients: [{ address: "joe@example.com", notify: [] }] },
|
|
320
|
+
{
|
|
321
|
+
recipients: [
|
|
322
|
+
{ address: "joe@example.com", notify: ["DELAY", "DELAY"] }
|
|
323
|
+
]
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
recipients: [{ address: "joe@example.com", orcpt: { type: "rfc822" } }]
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
recipients: [
|
|
330
|
+
{ address: "joe@example.com" },
|
|
331
|
+
{ address: "joe@example.com" }
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
];
|
|
335
|
+
for (const params of bad) {
|
|
336
|
+
await rejects(store.add(entry(params)), "INVALID");
|
|
337
|
+
}
|
|
338
|
+
expect4(await store.count()).toBe(0);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/conformance/items.ts
|
|
343
|
+
function describeItems(factories) {
|
|
344
|
+
describe3("items", () => {
|
|
345
|
+
adding(factories);
|
|
346
|
+
listing(factories);
|
|
347
|
+
dsnParameters(factories);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
function adding({ create }) {
|
|
351
|
+
test4("add keeps the envelope, every recipient pending, due at once", async () => {
|
|
352
|
+
const store = await create();
|
|
353
|
+
const item = await store.add(entry());
|
|
354
|
+
expect5(item).toMatchObject({
|
|
355
|
+
from: "mary@example.net",
|
|
356
|
+
recipients: [
|
|
357
|
+
{ address: "joe@example.com", status: "pending" },
|
|
358
|
+
{ address: "ann@example.org", status: "pending" }
|
|
359
|
+
],
|
|
360
|
+
size: 22,
|
|
361
|
+
createdAt: T0,
|
|
362
|
+
nextAttemptAt: T0,
|
|
363
|
+
attempts: 0,
|
|
364
|
+
delayNotified: false
|
|
365
|
+
});
|
|
366
|
+
expect5(item.lease).toBeUndefined();
|
|
367
|
+
expect5(await store.get(item.id)).toEqual(item);
|
|
368
|
+
expect5(await store.count()).toBe(1);
|
|
369
|
+
});
|
|
370
|
+
test4("the null sender is kept as the empty string", async () => {
|
|
371
|
+
const store = await create();
|
|
372
|
+
const item = await store.add(entry({ from: "" }));
|
|
373
|
+
expect5((await store.get(item.id))?.from).toBe("");
|
|
374
|
+
});
|
|
375
|
+
test4("readMessage gives the bytes back, a copy each time", async () => {
|
|
376
|
+
const store = await create();
|
|
377
|
+
const message = bytes(`Subject: x\r
|
|
378
|
+
\r
|
|
379
|
+
body\r
|
|
380
|
+
`);
|
|
381
|
+
const item = await store.add(entry({ message }));
|
|
382
|
+
message[0] = 0;
|
|
383
|
+
const read = await store.readMessage(item.id);
|
|
384
|
+
expect5(new TextDecoder().decode(read)).toBe(`Subject: x\r
|
|
385
|
+
\r
|
|
386
|
+
body\r
|
|
387
|
+
`);
|
|
388
|
+
if (read)
|
|
389
|
+
read[0] = 0;
|
|
390
|
+
expect5((await store.readMessage(item.id))?.[0]).toBe(83);
|
|
391
|
+
});
|
|
392
|
+
test4("an item returned is a copy", async () => {
|
|
393
|
+
const store = await create();
|
|
394
|
+
const item = await store.add(entry());
|
|
395
|
+
const first = item.recipients[0];
|
|
396
|
+
if (first)
|
|
397
|
+
first.status = "failed";
|
|
398
|
+
expect5((await store.get(item.id))?.recipients[0]?.status).toBe("pending");
|
|
399
|
+
});
|
|
400
|
+
test4("an unknown id is undefined, not an error", async () => {
|
|
401
|
+
const store = await create();
|
|
402
|
+
expect5(await store.get("nope")).toBeUndefined();
|
|
403
|
+
expect5(await store.readMessage("nope")).toBeUndefined();
|
|
404
|
+
expect5(await store.cancel("nope")).toBeUndefined();
|
|
405
|
+
expect5(await store.reschedule("nope", T0)).toBe(false);
|
|
406
|
+
});
|
|
407
|
+
test4("an id no store could hold, a NUL or a lone surrogate in it, is unknown too", async () => {
|
|
408
|
+
const store = await create();
|
|
409
|
+
await store.add(entry());
|
|
410
|
+
for (const id of ["a\x00b", "a\uD83D"]) {
|
|
411
|
+
expect5(await store.get(id)).toBeUndefined();
|
|
412
|
+
expect5(await store.readMessage(id)).toBeUndefined();
|
|
413
|
+
expect5(await store.renew(id, "w1", T0)).toBe(false);
|
|
414
|
+
expect5(await store.reschedule(id, T0)).toBe(false);
|
|
415
|
+
expect5(await store.cancel(id)).toBeUndefined();
|
|
416
|
+
}
|
|
417
|
+
expect5(await store.count()).toBe(1);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
function listing({ create }) {
|
|
421
|
+
test4("list gives the next due first, then the oldest, a page at a time", async () => {
|
|
422
|
+
const store = await create();
|
|
423
|
+
const a = await store.add(entry({ createdAt: T0 + 2 * MINUTE }));
|
|
424
|
+
const b = await store.add(entry({ createdAt: T0 }));
|
|
425
|
+
const c = await store.add(entry({ createdAt: T0 }));
|
|
426
|
+
const ids = (await store.list()).map((i) => i.id);
|
|
427
|
+
expect5(ids).toEqual([b.id, c.id, a.id]);
|
|
428
|
+
const page = await store.list({ offset: 1, limit: 1 });
|
|
429
|
+
expect5(page.map((i) => i.id)).toEqual([c.id]);
|
|
430
|
+
});
|
|
431
|
+
test4("list refuses a bad page", async () => {
|
|
432
|
+
const store = await create();
|
|
433
|
+
await rejects(store.list({ limit: 0 }), "INVALID");
|
|
434
|
+
await rejects(store.list({ offset: -1 }), "INVALID");
|
|
435
|
+
});
|
|
436
|
+
test4("cancel drops the item and its message, leased or not", async () => {
|
|
437
|
+
const store = await create();
|
|
438
|
+
const item = await store.add(entry());
|
|
439
|
+
await store.claim({ owner: "w1", now: T0, leaseMs: MINUTE });
|
|
440
|
+
const cancelled = await store.cancel(item.id);
|
|
441
|
+
expect5(cancelled?.id).toBe(item.id);
|
|
442
|
+
expect5(await store.get(item.id)).toBeUndefined();
|
|
443
|
+
expect5(await store.readMessage(item.id)).toBeUndefined();
|
|
444
|
+
expect5(await store.count()).toBe(0);
|
|
445
|
+
});
|
|
446
|
+
test4("maxItems refuses an add past the limit with QUEUE_FULL", async () => {
|
|
447
|
+
const store = await create();
|
|
448
|
+
await store.add(entry(), { maxItems: 2 });
|
|
449
|
+
await store.add(entry(), { maxItems: 2 });
|
|
450
|
+
await rejects(store.add(entry(), { maxItems: 2 }), "QUEUE_FULL");
|
|
451
|
+
expect5(await store.count()).toBe(2);
|
|
452
|
+
await rejects(store.add(entry(), { maxItems: 0 }), "INVALID");
|
|
453
|
+
});
|
|
454
|
+
test4("add refuses what is not an item", async () => {
|
|
455
|
+
const store = await create();
|
|
456
|
+
await rejects(store.add(entry({ to: [] })), "INVALID");
|
|
457
|
+
await rejects(store.add(entry({ message: "text" })), "INVALID");
|
|
458
|
+
await rejects(store.add(entry({ createdAt: Number.NaN })), "INVALID");
|
|
459
|
+
await rejects(store.add(entry({ from: "a\x00@example.net" })), "INVALID");
|
|
460
|
+
await rejects(store.add(entry({ to: ["jo\uD83D@example.com"] })), "INVALID");
|
|
461
|
+
expect5(await store.count()).toBe(0);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/conformance/outcomes.ts
|
|
466
|
+
import { describe as describe4, expect as expect8, test as test7 } from "bun:test";
|
|
467
|
+
|
|
468
|
+
// src/conformance/complete-dsns.ts
|
|
469
|
+
import { expect as expect6, test as test5 } from "bun:test";
|
|
470
|
+
var dsn = (text) => entry({
|
|
471
|
+
from: "",
|
|
472
|
+
to: ["mary@example.net"],
|
|
473
|
+
message: bytes(`Subject: ${text}\r
|
|
474
|
+
\r
|
|
475
|
+
${text}\r
|
|
476
|
+
`)
|
|
477
|
+
});
|
|
478
|
+
var delivered2 = result({
|
|
479
|
+
recipients: [
|
|
480
|
+
{ address: "joe@example.com", status: "delivered" },
|
|
481
|
+
{ address: "ann@example.org", status: "failed" }
|
|
482
|
+
]
|
|
483
|
+
});
|
|
484
|
+
function completeDsns(factories) {
|
|
485
|
+
stored(factories);
|
|
486
|
+
leaseHeld(factories);
|
|
487
|
+
refused(factories);
|
|
488
|
+
notFinal(factories);
|
|
489
|
+
}
|
|
490
|
+
function stored({ create }) {
|
|
491
|
+
test5("complete stores its DSNs with the outcome: new ids, in the order given, due at once", async () => {
|
|
492
|
+
const store = await create();
|
|
493
|
+
const item = await store.add(entry());
|
|
494
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
495
|
+
const done = await store.complete(item.id, "w1", delivered2, [
|
|
496
|
+
dsn("failed"),
|
|
497
|
+
dsn("relayed")
|
|
498
|
+
]);
|
|
499
|
+
expect6(done?.item.id).toBe(item.id);
|
|
500
|
+
expect6(done?.dsns).toHaveLength(2);
|
|
501
|
+
const [first, second] = done?.dsns ?? [];
|
|
502
|
+
expect6(first?.id).not.toBe(second?.id);
|
|
503
|
+
expect6([first?.id, second?.id]).not.toContain(item.id);
|
|
504
|
+
expect6(first).toMatchObject({ from: "", attempts: 0 });
|
|
505
|
+
expect6(await store.get(item.id)).toBeUndefined();
|
|
506
|
+
for (const [kept, subject] of [
|
|
507
|
+
[first, "failed"],
|
|
508
|
+
[second, "relayed"]
|
|
509
|
+
]) {
|
|
510
|
+
expect6(await store.get(kept?.id ?? "")).toEqual(kept);
|
|
511
|
+
expect6(new TextDecoder().decode(await store.readMessage(kept?.id ?? ""))).toContain(`Subject: ${subject}`);
|
|
512
|
+
}
|
|
513
|
+
expect6((await store.claim({ owner: "w2", now: T0 + MINUTE, ...lease }))?.id).toBe(first?.id);
|
|
514
|
+
expect6(await store.count()).toBe(2);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
function leaseHeld({ create }) {
|
|
518
|
+
test5("complete by an owner that no longer holds the lease stores no DSN, and a second one by the first stores none twice", async () => {
|
|
519
|
+
const store = await create();
|
|
520
|
+
const item = await store.add(entry());
|
|
521
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
522
|
+
expect6(await store.complete(item.id, "w2", delivered2, [dsn("failed")])).toBeUndefined();
|
|
523
|
+
expect6(await store.count()).toBe(1);
|
|
524
|
+
expect6(await store.complete(item.id, "w1", delivered2, [dsn("failed")])).toBeDefined();
|
|
525
|
+
expect6(await store.count()).toBe(1);
|
|
526
|
+
expect6(await store.complete(item.id, "w1", delivered2, [dsn("failed")])).toBeUndefined();
|
|
527
|
+
expect6(await store.count()).toBe(1);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function refused({ create }) {
|
|
531
|
+
test5("a DSN that fails the checks of add refuses the whole call with INVALID: no outcome, no DSN", async () => {
|
|
532
|
+
const store = await create();
|
|
533
|
+
const item = await store.add(entry());
|
|
534
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
535
|
+
await rejects(store.complete(item.id, "w1", delivered2, [
|
|
536
|
+
dsn("failed"),
|
|
537
|
+
{ ...dsn("bad"), to: [] }
|
|
538
|
+
]), "INVALID");
|
|
539
|
+
expect6(await store.count()).toBe(1);
|
|
540
|
+
expect6(await store.get(item.id)).toMatchObject({
|
|
541
|
+
attempts: 0,
|
|
542
|
+
lease: { owner: "w1" }
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
test5("dsns that are not an array refuse the call with INVALID, nothing recorded", async () => {
|
|
546
|
+
const store = await create();
|
|
547
|
+
const item = await store.add(entry());
|
|
548
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
549
|
+
await rejects(store.complete(item.id, "w1", delivered2, undefined), "INVALID");
|
|
550
|
+
expect6(await store.get(item.id)).toMatchObject({ attempts: 0 });
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
function notFinal({ create }) {
|
|
554
|
+
test5("complete with no DSN returns an empty list; a not-final item stays with its DSNs beside it", async () => {
|
|
555
|
+
const store = await create();
|
|
556
|
+
const item = await store.add(entry());
|
|
557
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
558
|
+
const none = await store.complete(item.id, "w1", result(), []);
|
|
559
|
+
expect6(none?.dsns).toEqual([]);
|
|
560
|
+
await store.claim({ owner: "w1", now: T0 + 30 * MINUTE, ...lease });
|
|
561
|
+
const deferred = result({
|
|
562
|
+
recipients: [{ address: "joe@example.com", status: "deferred" }],
|
|
563
|
+
attempts: 2,
|
|
564
|
+
delayNotified: true
|
|
565
|
+
});
|
|
566
|
+
const done = await store.complete(item.id, "w1", deferred, [
|
|
567
|
+
dsn("delayed")
|
|
568
|
+
]);
|
|
569
|
+
expect6(done?.item.delayNotified).toBe(true);
|
|
570
|
+
expect6(done?.dsns).toHaveLength(1);
|
|
571
|
+
expect6(await store.count()).toBe(2);
|
|
572
|
+
expect6(await store.get(item.id)).toEqual(done?.item);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/conformance/doubt.ts
|
|
577
|
+
import { expect as expect7, test as test6 } from "bun:test";
|
|
578
|
+
function doubt({ create }) {
|
|
579
|
+
const deferred = (mayHaveReachedMx) => ({
|
|
580
|
+
address: "joe@example.com",
|
|
581
|
+
status: "deferred",
|
|
582
|
+
reply: { status: "4.4.2", text: "no reply to the final dot" },
|
|
583
|
+
...mayHaveReachedMx === undefined ? {} : { mayHaveReachedMx }
|
|
584
|
+
});
|
|
585
|
+
test6("mayHaveReachedMx: recorded, read by get and list, and kept by a later outcome without it", async () => {
|
|
586
|
+
const store = await create();
|
|
587
|
+
const item = await store.add(entry());
|
|
588
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
589
|
+
const done = await store.complete(item.id, "w1", result({ recipients: [deferred(true)] }), []);
|
|
590
|
+
expect7(done?.item.recipients[0]?.mayHaveReachedMx).toBe(true);
|
|
591
|
+
expect7(done?.item.recipients[1]).not.toHaveProperty("mayHaveReachedMx");
|
|
592
|
+
expect7((await store.get(item.id))?.recipients[0]?.mayHaveReachedMx).toBe(true);
|
|
593
|
+
expect7((await store.list())[0]?.recipients[0]?.mayHaveReachedMx).toBe(true);
|
|
594
|
+
await store.claim({ owner: "w1", now: T0 + 30 * MINUTE, ...lease });
|
|
595
|
+
const again = await store.complete(item.id, "w1", result({ recipients: [deferred()], attempts: 2 }), []);
|
|
596
|
+
expect7(again?.item.recipients[0]?.mayHaveReachedMx).toBe(true);
|
|
597
|
+
expect7((await store.get(item.id))?.recipients[0]?.mayHaveReachedMx).toBe(true);
|
|
598
|
+
});
|
|
599
|
+
test6("mayHaveReachedMx: an unmarked recipient has no such key", async () => {
|
|
600
|
+
const store = await create();
|
|
601
|
+
const item = await store.add(entry());
|
|
602
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
603
|
+
await store.complete(item.id, "w1", result({ recipients: [deferred()] }), []);
|
|
604
|
+
for (const r of (await store.get(item.id))?.recipients ?? []) {
|
|
605
|
+
expect7(r).not.toHaveProperty("mayHaveReachedMx");
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
test6("mayHaveReachedMx: a value other than true is INVALID, and the item stays as it was", async () => {
|
|
609
|
+
const store = await create();
|
|
610
|
+
const item = await store.add(entry());
|
|
611
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
612
|
+
for (const bad of [false, "true", 1, null]) {
|
|
613
|
+
await rejects(store.complete(item.id, "w1", result({ recipients: [deferred(bad)] }), []), "INVALID");
|
|
614
|
+
}
|
|
615
|
+
const kept = await store.get(item.id);
|
|
616
|
+
expect7(kept?.recipients[0]?.status).toBe("pending");
|
|
617
|
+
expect7(kept?.lease?.owner).toBe("w1");
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
function fallbackDoubt({ create }) {
|
|
621
|
+
const deferred = (mayHaveReachedFallback) => ({
|
|
622
|
+
address: "joe@example.com",
|
|
623
|
+
status: "deferred",
|
|
624
|
+
reply: { status: "4.4.2", text: "no reply to the final dot" },
|
|
625
|
+
...mayHaveReachedFallback === undefined ? {} : { mayHaveReachedFallback }
|
|
626
|
+
});
|
|
627
|
+
test6("mayHaveReachedFallback: recorded, read by get and list, and kept by a later outcome without it", async () => {
|
|
628
|
+
const store = await create();
|
|
629
|
+
const item = await store.add(entry());
|
|
630
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
631
|
+
const done = await store.complete(item.id, "w1", result({ recipients: [deferred(true)] }), []);
|
|
632
|
+
expect7(done?.item.recipients[0]?.mayHaveReachedFallback).toBe(true);
|
|
633
|
+
expect7(done?.item.recipients[1]).not.toHaveProperty("mayHaveReachedFallback");
|
|
634
|
+
expect7((await store.get(item.id))?.recipients[0]?.mayHaveReachedFallback).toBe(true);
|
|
635
|
+
expect7((await store.list())[0]?.recipients[0]?.mayHaveReachedFallback).toBe(true);
|
|
636
|
+
await store.claim({ owner: "w1", now: T0 + 30 * MINUTE, ...lease });
|
|
637
|
+
const again = await store.complete(item.id, "w1", result({ recipients: [deferred()], attempts: 2 }), []);
|
|
638
|
+
expect7(again?.item.recipients[0]?.mayHaveReachedFallback).toBe(true);
|
|
639
|
+
expect7((await store.get(item.id))?.recipients[0]?.mayHaveReachedFallback).toBe(true);
|
|
640
|
+
});
|
|
641
|
+
test6("mayHaveReachedFallback: an unmarked recipient has no such key", async () => {
|
|
642
|
+
const store = await create();
|
|
643
|
+
const item = await store.add(entry());
|
|
644
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
645
|
+
await store.complete(item.id, "w1", result({ recipients: [deferred()] }), []);
|
|
646
|
+
for (const r of (await store.get(item.id))?.recipients ?? []) {
|
|
647
|
+
expect7(r).not.toHaveProperty("mayHaveReachedFallback");
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
test6("mayHaveReachedFallback: a value other than true is INVALID, and the item stays as it was", async () => {
|
|
651
|
+
const store = await create();
|
|
652
|
+
const item = await store.add(entry());
|
|
653
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
654
|
+
for (const bad of [false, "true", 1, null]) {
|
|
655
|
+
await rejects(store.complete(item.id, "w1", result({ recipients: [deferred(bad)] }), []), "INVALID");
|
|
656
|
+
}
|
|
657
|
+
const kept = await store.get(item.id);
|
|
658
|
+
expect7(kept?.recipients[0]?.status).toBe("pending");
|
|
659
|
+
expect7(kept?.lease?.owner).toBe("w1");
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/conformance/outcomes.ts
|
|
664
|
+
function describeOutcomes(factories) {
|
|
665
|
+
describe4("outcomes", () => {
|
|
666
|
+
recording(factories);
|
|
667
|
+
refusing(factories);
|
|
668
|
+
finality(factories);
|
|
669
|
+
completeDsns(factories);
|
|
670
|
+
doubt(factories);
|
|
671
|
+
fallbackDoubt(factories);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
function recording({ create }) {
|
|
675
|
+
test7("complete records each outcome, the schedule, and lets go of the lease", async () => {
|
|
676
|
+
const store = await create();
|
|
677
|
+
const item = await store.add(entry());
|
|
678
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
679
|
+
const reply = {
|
|
680
|
+
code: 451,
|
|
681
|
+
status: "4.3.0",
|
|
682
|
+
text: "try later",
|
|
683
|
+
host: "mx.example.com"
|
|
684
|
+
};
|
|
685
|
+
const done = await store.complete(item.id, "w1", result({
|
|
686
|
+
recipients: [{ address: "joe@example.com", status: "deferred", reply }]
|
|
687
|
+
}), []);
|
|
688
|
+
expect8(done?.item.recipients).toEqual([
|
|
689
|
+
{
|
|
690
|
+
address: "joe@example.com",
|
|
691
|
+
status: "deferred",
|
|
692
|
+
reply,
|
|
693
|
+
updatedAt: T0 + MINUTE
|
|
694
|
+
},
|
|
695
|
+
{ address: "ann@example.org", status: "pending" }
|
|
696
|
+
]);
|
|
697
|
+
const stored = await store.get(item.id);
|
|
698
|
+
expect8(stored).toEqual(done?.item);
|
|
699
|
+
expect8(stored).toMatchObject({
|
|
700
|
+
nextAttemptAt: T0 + 30 * MINUTE,
|
|
701
|
+
attempts: 1
|
|
702
|
+
});
|
|
703
|
+
expect8(stored?.lease).toBeUndefined();
|
|
704
|
+
expect8(await store.claim({ owner: "w2", now: T0 + 29 * MINUTE, ...lease })).toBeUndefined();
|
|
705
|
+
expect8(await store.claim({ owner: "w2", now: T0 + 30 * MINUTE, ...lease })).toBeDefined();
|
|
706
|
+
});
|
|
707
|
+
test7("an item whose every recipient is final is dropped with its message", async () => {
|
|
708
|
+
const store = await create();
|
|
709
|
+
const item = await store.add(entry());
|
|
710
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
711
|
+
const done = await store.complete(item.id, "w1", result({
|
|
712
|
+
recipients: [
|
|
713
|
+
{
|
|
714
|
+
address: "joe@example.com",
|
|
715
|
+
status: "delivered",
|
|
716
|
+
reply: { code: 250, text: "ok" }
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
address: "ann@example.org",
|
|
720
|
+
status: "failed",
|
|
721
|
+
reply: { code: 550, text: "no" }
|
|
722
|
+
}
|
|
723
|
+
],
|
|
724
|
+
delayNotified: true
|
|
725
|
+
}), []);
|
|
726
|
+
expect8(done?.item.recipients.map((r) => r.status)).toEqual([
|
|
727
|
+
"delivered",
|
|
728
|
+
"failed"
|
|
729
|
+
]);
|
|
730
|
+
expect8(done?.item.delayNotified).toBe(true);
|
|
731
|
+
expect8(await store.get(item.id)).toBeUndefined();
|
|
732
|
+
expect8(await store.readMessage(item.id)).toBeUndefined();
|
|
733
|
+
expect8(await store.count()).toBe(0);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
function refusing({ create }) {
|
|
737
|
+
test7("an outcome holding a NUL or a lone surrogate is refused, and the item stays as it was", async () => {
|
|
738
|
+
const store = await create();
|
|
739
|
+
const item = await store.add(entry());
|
|
740
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
741
|
+
const bad = [
|
|
742
|
+
{ code: 451, text: `${"a".repeat(8)}\uD83D` },
|
|
743
|
+
{ code: 451, text: "nul \x00 here" },
|
|
744
|
+
{ code: 451, text: "ok", host: "mx\uDC00.example.com" },
|
|
745
|
+
{ code: 451, status: "4.3.0\x00", text: "ok" }
|
|
746
|
+
];
|
|
747
|
+
for (const reply of bad) {
|
|
748
|
+
await rejects(store.complete(item.id, "w1", result({
|
|
749
|
+
recipients: [
|
|
750
|
+
{ address: "joe@example.com", status: "deferred", reply }
|
|
751
|
+
]
|
|
752
|
+
}), []), "INVALID");
|
|
753
|
+
}
|
|
754
|
+
await rejects(store.claim({ owner: "w\uD800", now: T0, ...lease }), "INVALID");
|
|
755
|
+
const stored = await store.get(item.id);
|
|
756
|
+
expect8(stored?.attempts).toBe(0);
|
|
757
|
+
expect8(stored?.lease?.owner).toBe("w1");
|
|
758
|
+
const done = await store.complete(item.id, "w1", result({
|
|
759
|
+
recipients: [
|
|
760
|
+
{
|
|
761
|
+
address: "joe@example.com",
|
|
762
|
+
status: "deferred",
|
|
763
|
+
reply: { code: 451, text: "Try later \uD83D\uDE00" }
|
|
764
|
+
}
|
|
765
|
+
]
|
|
766
|
+
}), []);
|
|
767
|
+
expect8(done?.item.recipients[0]?.reply?.text).toBe("Try later \uD83D\uDE00");
|
|
768
|
+
expect8((await store.get(item.id))?.recipients[0]?.reply?.text).toBe("Try later \uD83D\uDE00");
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function finality({ create }) {
|
|
772
|
+
test7("a final state is never changed by a later outcome", async () => {
|
|
773
|
+
const store = await create();
|
|
774
|
+
const item = await store.add(entry());
|
|
775
|
+
await store.claim({ owner: "w1", now: T0, ...lease });
|
|
776
|
+
await store.complete(item.id, "w1", result({
|
|
777
|
+
recipients: [{ address: "joe@example.com", status: "delivered" }]
|
|
778
|
+
}), []);
|
|
779
|
+
await store.claim({ owner: "w1", now: T0 + 30 * MINUTE, ...lease });
|
|
780
|
+
const after = await store.complete(item.id, "w1", result({
|
|
781
|
+
recipients: [{ address: "joe@example.com", status: "failed" }],
|
|
782
|
+
attempts: 2
|
|
783
|
+
}), []);
|
|
784
|
+
expect8(after?.item.recipients[0]?.status).toBe("delivered");
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/conformance/describe-queue-store.ts
|
|
789
|
+
function describeQueueStore(name, harness) {
|
|
790
|
+
describe5(`${name}: the QueueStore contract`, () => {
|
|
791
|
+
const created = [];
|
|
792
|
+
const factories = {
|
|
793
|
+
share: harness.share,
|
|
794
|
+
create: async () => {
|
|
795
|
+
const store = await harness.create();
|
|
796
|
+
created.push(store);
|
|
797
|
+
return store;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
afterEach(async () => {
|
|
801
|
+
const stores = created.splice(0);
|
|
802
|
+
const settled = await Promise.allSettled(stores.map(async (store) => harness.dispose?.(store)));
|
|
803
|
+
const failed = settled.filter((r) => r.status === "rejected");
|
|
804
|
+
if (failed.length === 1)
|
|
805
|
+
throw failed[0]?.reason;
|
|
806
|
+
if (failed.length > 1)
|
|
807
|
+
throw new AggregateError(failed.map((r) => r.reason), "several stores failed to dispose");
|
|
808
|
+
});
|
|
809
|
+
describeItems(factories);
|
|
810
|
+
describeClaims(factories);
|
|
811
|
+
describeOutcomes(factories);
|
|
812
|
+
describeConcurrency(factories);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
export {
|
|
816
|
+
describeQueueStore
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
//# debugId=434F5188249F7D1F64756E2164756E21
|
|
820
|
+
//# sourceMappingURL=index.js.map
|