@nymbalhq/contract-tests 1.2.0 → 1.4.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/dist/{chunk-ABEL44IM.js → chunk-KK53D2KL.js} +1 -2
- package/dist/{chunk-ABEL44IM.js.map → chunk-KK53D2KL.js.map} +1 -1
- package/dist/index.d.ts +24 -8
- package/dist/index.js +766 -357
- package/dist/index.js.map +1 -1
- package/dist/kinds.d.ts +1 -1
- package/dist/kinds.js +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CONTRACT_KINDS
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-KK53D2KL.js";
|
|
4
4
|
|
|
5
|
-
// src/
|
|
5
|
+
// src/analytics.ts
|
|
6
6
|
import { describe as describe2, expect as expect2, it as it2 } from "vitest";
|
|
7
7
|
|
|
8
8
|
// src/common.ts
|
|
@@ -94,59 +94,24 @@ function healthAndDowntime(build, unreachable, label, operation) {
|
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// src/ai.ts
|
|
98
|
-
function runAiContract(name, build, options = {}) {
|
|
99
|
-
describe2(`AiAdapter contract: ${name}`, () => {
|
|
100
|
-
it2(
|
|
101
|
-
"embed returns embedding vectors with correct dimensions",
|
|
102
|
-
async () => {
|
|
103
|
-
const adapter = await build();
|
|
104
|
-
const embeddings = await adapter.embed(["hello world", "test text"]);
|
|
105
|
-
expect2(Array.isArray(embeddings)).toBe(true);
|
|
106
|
-
expect2(embeddings).toHaveLength(2);
|
|
107
|
-
expect2(Array.isArray(embeddings[0])).toBe(true);
|
|
108
|
-
expect2((embeddings[0] ?? []).length).toBeGreaterThan(0);
|
|
109
|
-
},
|
|
110
|
-
CASE_MS
|
|
111
|
-
);
|
|
112
|
-
it2(
|
|
113
|
-
"complete returns a non-empty string",
|
|
114
|
-
async () => {
|
|
115
|
-
const adapter = await build();
|
|
116
|
-
const result = await adapter.complete("Write one word: hello", { maxTokens: 10 });
|
|
117
|
-
expect2(typeof result).toBe("string");
|
|
118
|
-
expect2(result.length).toBeGreaterThan(0);
|
|
119
|
-
},
|
|
120
|
-
CASE_MS
|
|
121
|
-
);
|
|
122
|
-
healthAndDowntime(
|
|
123
|
-
build,
|
|
124
|
-
options.unreachable,
|
|
125
|
-
"complete()",
|
|
126
|
-
(adapter) => adapter.complete("Write one word: hello", { maxTokens: 10 })
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
97
|
// src/analytics.ts
|
|
132
|
-
import { describe as describe3, expect as expect3, it as it3 } from "vitest";
|
|
133
98
|
function runAnalyticsContract(name, build, options = {}) {
|
|
134
|
-
|
|
135
|
-
|
|
99
|
+
describe2(`AnalyticsAdapter contract: ${name}`, () => {
|
|
100
|
+
it2(
|
|
136
101
|
"track resolves without throwing",
|
|
137
102
|
async () => {
|
|
138
103
|
const adapter = await build();
|
|
139
|
-
await
|
|
104
|
+
await expect2(
|
|
140
105
|
adapter.track("product_viewed", { productId: "prod-1", source: "test" })
|
|
141
106
|
).resolves.not.toThrow();
|
|
142
107
|
},
|
|
143
108
|
CASE_MS
|
|
144
109
|
);
|
|
145
|
-
|
|
110
|
+
it2(
|
|
146
111
|
"identify resolves without throwing",
|
|
147
112
|
async () => {
|
|
148
113
|
const adapter = await build();
|
|
149
|
-
await
|
|
114
|
+
await expect2(
|
|
150
115
|
adapter.identify("user-1", { email: "test@example.com", plan: "free" })
|
|
151
116
|
).resolves.not.toThrow();
|
|
152
117
|
},
|
|
@@ -162,11 +127,11 @@ function runAnalyticsContract(name, build, options = {}) {
|
|
|
162
127
|
}
|
|
163
128
|
|
|
164
129
|
// src/document-store.ts
|
|
165
|
-
import { describe as
|
|
130
|
+
import { describe as describe4, expect as expect5, it as it5 } from "vitest";
|
|
166
131
|
|
|
167
132
|
// src/document-store-model.ts
|
|
168
133
|
import fc from "fast-check";
|
|
169
|
-
import { describe as
|
|
134
|
+
import { describe as describe3, expect as expect3, it as it3 } from "vitest";
|
|
170
135
|
import {
|
|
171
136
|
compareDocumentValues,
|
|
172
137
|
matchesSortKey
|
|
@@ -296,8 +261,8 @@ async function rejection(promise) {
|
|
|
296
261
|
}
|
|
297
262
|
var TTL_SECONDS = 1;
|
|
298
263
|
function documentStoreModelCases(make, opts) {
|
|
299
|
-
|
|
300
|
-
|
|
264
|
+
describe3("agrees with the reference model", () => {
|
|
265
|
+
it3(
|
|
301
266
|
"every query\u2019s page sequence equals the reference: filter by partition and sortKey, order with nulls last and ties by key, slice by limit",
|
|
302
267
|
async () => {
|
|
303
268
|
await fc.assert(
|
|
@@ -313,11 +278,11 @@ function documentStoreModelCases(make, opts) {
|
|
|
313
278
|
const q = { partition, sortKey, orderBy };
|
|
314
279
|
const expected = reference(docs, q);
|
|
315
280
|
const { pages, ended } = await walk(store, q, limit, docs.length + 2);
|
|
316
|
-
|
|
317
|
-
for (const page of pages)
|
|
318
|
-
|
|
281
|
+
expect3(ended, "the cursor walk never ended").toBe(true);
|
|
282
|
+
for (const page of pages) expect3(page.length).toBeLessThanOrEqual(limit);
|
|
283
|
+
expect3(withoutTrailingEmpty(pages.map(idsOf))).toEqual(chunk(expected, limit));
|
|
319
284
|
const byId = new Map(docs.map((d) => [d.id, d]));
|
|
320
|
-
for (const item of pages.flat())
|
|
285
|
+
for (const item of pages.flat()) expect3(item).toEqual(byId.get(item.id));
|
|
321
286
|
});
|
|
322
287
|
}
|
|
323
288
|
),
|
|
@@ -326,7 +291,7 @@ function documentStoreModelCases(make, opts) {
|
|
|
326
291
|
},
|
|
327
292
|
SLOW
|
|
328
293
|
);
|
|
329
|
-
|
|
294
|
+
it3(
|
|
330
295
|
"without orderBy, a cursor walk returns exactly the reference\u2019s matches, each once",
|
|
331
296
|
async () => {
|
|
332
297
|
await fc.assert(
|
|
@@ -341,11 +306,11 @@ function documentStoreModelCases(make, opts) {
|
|
|
341
306
|
const q = { partition, sortKey, orderBy: void 0 };
|
|
342
307
|
const { pages, ended } = await walk(store, q, limit, docs.length + 2);
|
|
343
308
|
const walked = idsOf(pages.flat());
|
|
344
|
-
|
|
345
|
-
|
|
309
|
+
expect3(ended, "the cursor walk never ended").toBe(true);
|
|
310
|
+
expect3(walked.length, `duplicates in ${walked.join(",")}`).toBe(
|
|
346
311
|
new Set(walked).size
|
|
347
312
|
);
|
|
348
|
-
|
|
313
|
+
expect3([...walked].sort()).toEqual(reference(docs, q));
|
|
349
314
|
});
|
|
350
315
|
}
|
|
351
316
|
),
|
|
@@ -354,7 +319,7 @@ function documentStoreModelCases(make, opts) {
|
|
|
354
319
|
},
|
|
355
320
|
SLOW
|
|
356
321
|
);
|
|
357
|
-
|
|
322
|
+
it3(
|
|
358
323
|
"a cursor walk across a mid-walk delete returns every remaining match exactly once, in the reference order",
|
|
359
324
|
async () => {
|
|
360
325
|
await fc.assert(
|
|
@@ -374,14 +339,14 @@ function documentStoreModelCases(make, opts) {
|
|
|
374
339
|
if (n === 1) await store.delete(COLLECTION, deleted);
|
|
375
340
|
});
|
|
376
341
|
const walked = idsOf(pages.flat());
|
|
377
|
-
|
|
378
|
-
|
|
342
|
+
expect3(ended, "the cursor walk never ended").toBe(true);
|
|
343
|
+
expect3(walked.length, `duplicates in ${walked.join(",")}`).toBe(
|
|
379
344
|
new Set(walked).size
|
|
380
345
|
);
|
|
381
|
-
|
|
382
|
-
|
|
346
|
+
expect3(walked.filter((id) => !expected.includes(id))).toEqual([]);
|
|
347
|
+
expect3(expected.filter((id) => id !== deleted && !walked.includes(id))).toEqual([]);
|
|
383
348
|
if (orderBy !== void 0) {
|
|
384
|
-
|
|
349
|
+
expect3(walked).toEqual(expected.filter((id) => walked.includes(id)));
|
|
385
350
|
}
|
|
386
351
|
});
|
|
387
352
|
}
|
|
@@ -391,7 +356,7 @@ function documentStoreModelCases(make, opts) {
|
|
|
391
356
|
},
|
|
392
357
|
SLOW
|
|
393
358
|
);
|
|
394
|
-
|
|
359
|
+
it3(
|
|
395
360
|
"a cursor issued under one orderBy is refused under another, and continues the walk under its own",
|
|
396
361
|
async () => {
|
|
397
362
|
const generated = ["k1", "k2", "k3", "k4", "k5"].map((key, i) => ({
|
|
@@ -408,23 +373,23 @@ function documentStoreModelCases(make, opts) {
|
|
|
408
373
|
orderBy: { field: "v", direction: "asc" }
|
|
409
374
|
};
|
|
410
375
|
const first = await store.query(COLLECTION, params(asc, 2));
|
|
411
|
-
|
|
376
|
+
expect3(first.nextCursor).not.toBeNull();
|
|
412
377
|
const cursor = first.nextCursor;
|
|
413
378
|
const otherDirection = { ...asc, orderBy: { field: "v", direction: "desc" } };
|
|
414
379
|
const otherField = { ...asc, orderBy: { field: "nested.v", direction: "asc" } };
|
|
415
|
-
|
|
380
|
+
expect3(
|
|
416
381
|
await rejection(store.query(COLLECTION, params(otherDirection, 2, cursor)))
|
|
417
382
|
).toBeInstanceOf(Error);
|
|
418
|
-
|
|
383
|
+
expect3(
|
|
419
384
|
await rejection(store.query(COLLECTION, params(otherField, 2, cursor)))
|
|
420
385
|
).toBeInstanceOf(Error);
|
|
421
386
|
const next = await store.query(COLLECTION, params(asc, 2, cursor));
|
|
422
|
-
|
|
387
|
+
expect3(idsOf(next.items)).toEqual(reference(docs, asc).slice(2, 4));
|
|
423
388
|
});
|
|
424
389
|
},
|
|
425
390
|
SLOW
|
|
426
391
|
);
|
|
427
|
-
|
|
392
|
+
it3(
|
|
428
393
|
"a document put with a TTL is returned until it expires, then by no get, query or batchGet; one put without stays",
|
|
429
394
|
async () => {
|
|
430
395
|
const store = await make();
|
|
@@ -434,33 +399,33 @@ function documentStoreModelCases(make, opts) {
|
|
|
434
399
|
const putAt = Date.now();
|
|
435
400
|
await store.put(COLLECTION, short.id, short, { ttl: TTL_SECONDS });
|
|
436
401
|
await store.put(COLLECTION, long.id, long);
|
|
437
|
-
|
|
402
|
+
expect3(await store.get(COLLECTION, short.id)).toEqual(short);
|
|
438
403
|
if (opts.clock !== void 0) {
|
|
439
404
|
await opts.clock.advance(TTL_SECONDS * 1e3 - 1);
|
|
440
|
-
|
|
405
|
+
expect3(await store.get(COLLECTION, short.id)).toEqual(short);
|
|
441
406
|
await opts.clock.advance(2);
|
|
442
407
|
} else {
|
|
443
408
|
const deadline = putAt + TTL_SECONDS * 1e3 + 3e3;
|
|
444
409
|
while (await store.get(COLLECTION, short.id) !== null && Date.now() < deadline) {
|
|
445
410
|
await new Promise((r) => setTimeout(r, 25));
|
|
446
411
|
}
|
|
447
|
-
|
|
412
|
+
expect3(Date.now() - putAt, "expired before its TTL").toBeGreaterThanOrEqual(
|
|
448
413
|
TTL_SECONDS * 1e3 - 100
|
|
449
414
|
);
|
|
450
415
|
}
|
|
451
|
-
|
|
416
|
+
expect3(await store.get(COLLECTION, short.id)).toBeNull();
|
|
452
417
|
const all = await store.query(COLLECTION, {
|
|
453
418
|
partitionKey: { field: "part", value: "*" },
|
|
454
419
|
limit: 100
|
|
455
420
|
});
|
|
456
|
-
|
|
421
|
+
expect3(idsOf(all.items)).toEqual([long.id]);
|
|
457
422
|
const batch = await store.batchGet(COLLECTION, [short.id, long.id]);
|
|
458
|
-
|
|
423
|
+
expect3([...batch.keys()]).toEqual([long.id]);
|
|
459
424
|
});
|
|
460
425
|
},
|
|
461
426
|
SLOW
|
|
462
427
|
);
|
|
463
|
-
|
|
428
|
+
it3("a conditional put applies when the field matches, and otherwise fails condition-failed and changes nothing", async () => {
|
|
464
429
|
const store = await make();
|
|
465
430
|
await closing(store, async () => {
|
|
466
431
|
const v1 = {
|
|
@@ -473,7 +438,7 @@ function documentStoreModelCases(make, opts) {
|
|
|
473
438
|
const v2 = { ...v1, label: "v2", version: 2 };
|
|
474
439
|
await store.put(COLLECTION, "c", v1);
|
|
475
440
|
await store.put(COLLECTION, "c", v2, { conditionKey: "version", conditionValue: 1 });
|
|
476
|
-
|
|
441
|
+
expect3(await store.get(COLLECTION, "c")).toEqual(v2);
|
|
477
442
|
const stale = await rejection(
|
|
478
443
|
store.put(
|
|
479
444
|
COLLECTION,
|
|
@@ -482,16 +447,16 @@ function documentStoreModelCases(make, opts) {
|
|
|
482
447
|
{ conditionKey: "version", conditionValue: 1 }
|
|
483
448
|
)
|
|
484
449
|
);
|
|
485
|
-
|
|
486
|
-
|
|
450
|
+
expect3(stale?.code).toBe("adapter.condition-failed");
|
|
451
|
+
expect3(await store.get(COLLECTION, "c")).toEqual(v2);
|
|
487
452
|
const absent = await rejection(
|
|
488
453
|
store.put(COLLECTION, "none", v1, { conditionKey: "version", conditionValue: 1 })
|
|
489
454
|
);
|
|
490
|
-
|
|
491
|
-
|
|
455
|
+
expect3(absent?.code).toBe("adapter.condition-failed");
|
|
456
|
+
expect3(await store.get(COLLECTION, "none")).toBeNull();
|
|
492
457
|
});
|
|
493
458
|
}, CASE_MS);
|
|
494
|
-
|
|
459
|
+
it3(
|
|
495
460
|
"batchPut stores every item and batchGet returns exactly the stored keys, equal to what was put",
|
|
496
461
|
async () => {
|
|
497
462
|
await fc.assert(
|
|
@@ -502,10 +467,10 @@ function documentStoreModelCases(make, opts) {
|
|
|
502
467
|
await store.batchPut(COLLECTION, new Map(docs.map((d) => [d.id, d])));
|
|
503
468
|
const asked = [...docs.map((d) => d.id), "contract-absent"];
|
|
504
469
|
const got = await store.batchGet(COLLECTION, asked);
|
|
505
|
-
|
|
470
|
+
expect3(got.size).toBe(docs.length);
|
|
506
471
|
for (const doc of docs) {
|
|
507
|
-
|
|
508
|
-
|
|
472
|
+
expect3(got.get(doc.id)).toEqual(doc);
|
|
473
|
+
expect3(await store.get(COLLECTION, doc.id)).toEqual(doc);
|
|
509
474
|
}
|
|
510
475
|
});
|
|
511
476
|
}),
|
|
@@ -514,7 +479,7 @@ function documentStoreModelCases(make, opts) {
|
|
|
514
479
|
},
|
|
515
480
|
SLOW
|
|
516
481
|
);
|
|
517
|
-
|
|
482
|
+
it3("collections are isolated for get, query and delete", async () => {
|
|
518
483
|
const store = await make();
|
|
519
484
|
await closing(store, async () => {
|
|
520
485
|
const a = { id: "same", part: "p", nested: {}, label: "A" };
|
|
@@ -523,21 +488,21 @@ function documentStoreModelCases(make, opts) {
|
|
|
523
488
|
await store.put("contract-col-a", a.id, a);
|
|
524
489
|
await store.put("contract-col-a", onlyA.id, onlyA);
|
|
525
490
|
await store.put("contract-col-b", b.id, b);
|
|
526
|
-
|
|
527
|
-
|
|
491
|
+
expect3(await store.get("contract-col-a", "same")).toEqual(a);
|
|
492
|
+
expect3(await store.get("contract-col-b", "same")).toEqual(b);
|
|
528
493
|
const inB = await store.query("contract-col-b", {
|
|
529
494
|
partitionKey: { field: "part", value: "*" },
|
|
530
495
|
limit: 100
|
|
531
496
|
});
|
|
532
|
-
|
|
497
|
+
expect3(idsOf(inB.items)).toEqual([b.id]);
|
|
533
498
|
await store.delete("contract-col-a", "same");
|
|
534
|
-
|
|
535
|
-
|
|
499
|
+
expect3(await store.get("contract-col-b", "same")).toEqual(b);
|
|
500
|
+
expect3(await store.get("contract-col-a", "only-a")).toEqual(onlyA);
|
|
536
501
|
});
|
|
537
502
|
}, CASE_MS);
|
|
538
503
|
if (opts.isolation !== void 0) {
|
|
539
504
|
const isolation = opts.isolation;
|
|
540
|
-
|
|
505
|
+
it3("two instances with different prefixes never see each other\u2019s documents", async () => {
|
|
541
506
|
const [one, two] = await isolation();
|
|
542
507
|
try {
|
|
543
508
|
const mine = { id: "shared-key", part: "p", nested: {}, label: "one" };
|
|
@@ -545,18 +510,18 @@ function documentStoreModelCases(make, opts) {
|
|
|
545
510
|
await one.put(COLLECTION, mine.id, mine);
|
|
546
511
|
await one.put(COLLECTION, "one-only", { ...mine, id: "one-only" });
|
|
547
512
|
await two.put(COLLECTION, theirs.id, theirs);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
513
|
+
expect3(await one.get(COLLECTION, mine.id)).toEqual(mine);
|
|
514
|
+
expect3(await two.get(COLLECTION, theirs.id)).toEqual(theirs);
|
|
515
|
+
expect3(await two.get(COLLECTION, "one-only")).toBeNull();
|
|
551
516
|
const inTwo = await two.query(COLLECTION, {
|
|
552
517
|
partitionKey: { field: "part", value: "*" },
|
|
553
518
|
limit: 100
|
|
554
519
|
});
|
|
555
|
-
|
|
520
|
+
expect3(idsOf(inTwo.items)).toEqual([theirs.id]);
|
|
556
521
|
const batch = await two.batchGet(COLLECTION, [mine.id, "one-only"]);
|
|
557
|
-
|
|
522
|
+
expect3([...batch.values()]).toEqual([theirs]);
|
|
558
523
|
await one.delete(COLLECTION, mine.id);
|
|
559
|
-
|
|
524
|
+
expect3(await two.get(COLLECTION, theirs.id)).toEqual(theirs);
|
|
560
525
|
} finally {
|
|
561
526
|
await one.close();
|
|
562
527
|
await two.close();
|
|
@@ -568,7 +533,7 @@ function documentStoreModelCases(make, opts) {
|
|
|
568
533
|
|
|
569
534
|
// src/document-store-replace.ts
|
|
570
535
|
import fc2 from "fast-check";
|
|
571
|
-
import { expect as
|
|
536
|
+
import { expect as expect4, it as it4 } from "vitest";
|
|
572
537
|
var SLOW2 = 6e4;
|
|
573
538
|
var KEYS = ["a", "b", "c", "d", "e", "f", "g", "h"];
|
|
574
539
|
var macrotask = () => new Promise((resolve) => setImmediate(resolve));
|
|
@@ -588,7 +553,7 @@ async function putAll(store, collection, docs) {
|
|
|
588
553
|
}
|
|
589
554
|
var docsFrom = (keys, v) => keys.map((id) => ({ id, part: "p", v }));
|
|
590
555
|
function documentStoreReplaceCases(make, opts) {
|
|
591
|
-
|
|
556
|
+
it4(
|
|
592
557
|
"replaceCollection leaves exactly the new set, whatever the old and new sets: no stale key of the old set survives, and another collection is untouched (property)",
|
|
593
558
|
async () => {
|
|
594
559
|
await using(make, async (store) => {
|
|
@@ -605,11 +570,11 @@ function documentStoreReplaceCases(make, opts) {
|
|
|
605
570
|
await putAll(store, decoy, docsFrom(oldKeys, 3));
|
|
606
571
|
const next = docsFrom(newKeys, 2);
|
|
607
572
|
await store.replaceCollection(collection, entriesOf(next));
|
|
608
|
-
|
|
573
|
+
expect4(await snapshot(store, collection)).toEqual(shape(next));
|
|
609
574
|
for (const key of oldKeys.filter((k) => !newKeys.includes(k))) {
|
|
610
|
-
|
|
575
|
+
expect4(await store.get(collection, key), `stale key ${key}`).toBeNull();
|
|
611
576
|
}
|
|
612
|
-
|
|
577
|
+
expect4(await snapshot(store, decoy)).toEqual(shape(docsFrom(oldKeys, 3)));
|
|
613
578
|
}
|
|
614
579
|
),
|
|
615
580
|
{ numRuns: 25 }
|
|
@@ -618,7 +583,7 @@ function documentStoreReplaceCases(make, opts) {
|
|
|
618
583
|
},
|
|
619
584
|
SLOW2
|
|
620
585
|
);
|
|
621
|
-
|
|
586
|
+
it4(
|
|
622
587
|
"replaceCollection is atomic to a concurrent reader: polling while the replace runs sees only the full old set or the full new set, never a partial set",
|
|
623
588
|
async () => {
|
|
624
589
|
await using(make, async (store) => {
|
|
@@ -638,19 +603,19 @@ function documentStoreReplaceCases(make, opts) {
|
|
|
638
603
|
done = true;
|
|
639
604
|
});
|
|
640
605
|
await reader;
|
|
641
|
-
|
|
606
|
+
expect4(seen.length, "the reader read while the replace ran").toBeGreaterThan(0);
|
|
642
607
|
for (const observed of seen) {
|
|
643
|
-
|
|
608
|
+
expect4(
|
|
644
609
|
allowed.has(JSON.stringify(observed)),
|
|
645
610
|
`a partial set: ${observed.join(" ")}`
|
|
646
611
|
).toBe(true);
|
|
647
612
|
}
|
|
648
|
-
|
|
613
|
+
expect4(await snapshot(store, "contract-replace-reader")).toEqual(shape(next));
|
|
649
614
|
});
|
|
650
615
|
},
|
|
651
616
|
CASE_MS
|
|
652
617
|
);
|
|
653
|
-
|
|
618
|
+
it4(
|
|
654
619
|
"replaceCollection whose source throws part-way rejects with the source\u2019s error and leaves the old set intact",
|
|
655
620
|
async () => {
|
|
656
621
|
await using(make, async (store) => {
|
|
@@ -665,34 +630,34 @@ function documentStoreReplaceCases(make, opts) {
|
|
|
665
630
|
() => null,
|
|
666
631
|
(error) => error
|
|
667
632
|
);
|
|
668
|
-
|
|
669
|
-
|
|
633
|
+
expect4(String(failed)).toContain("contract: the source failed");
|
|
634
|
+
expect4(await snapshot(store, "contract-replace-failing")).toEqual(shape(old));
|
|
670
635
|
});
|
|
671
636
|
},
|
|
672
637
|
CASE_MS
|
|
673
638
|
);
|
|
674
|
-
|
|
639
|
+
it4(
|
|
675
640
|
"replaceCollection with an empty source empties the collection",
|
|
676
641
|
async () => {
|
|
677
642
|
await using(make, async (store) => {
|
|
678
643
|
await putAll(store, "contract-replace-empty", docsFrom(["a", "b"], 1));
|
|
679
644
|
await store.replaceCollection("contract-replace-empty", entriesOf([]));
|
|
680
|
-
|
|
681
|
-
|
|
645
|
+
expect4(await snapshot(store, "contract-replace-empty")).toEqual([]);
|
|
646
|
+
expect4(await store.get("contract-replace-empty", "a")).toBeNull();
|
|
682
647
|
});
|
|
683
648
|
},
|
|
684
649
|
CASE_MS
|
|
685
650
|
);
|
|
686
651
|
const clock = opts.clock;
|
|
687
652
|
if (clock !== void 0) {
|
|
688
|
-
|
|
653
|
+
it4(
|
|
689
654
|
"replaceCollection writes documents without a TTL: a key whose old document had one is still there after that TTL passes",
|
|
690
655
|
async () => {
|
|
691
656
|
await using(make, async (store) => {
|
|
692
657
|
await store.put("contract-replace-ttl", "a", { id: "a", part: "p", v: 1 }, { ttl: 60 });
|
|
693
658
|
await store.replaceCollection("contract-replace-ttl", entriesOf(docsFrom(["a", "b"], 2)));
|
|
694
659
|
await clock.advance(61e3);
|
|
695
|
-
|
|
660
|
+
expect4(await snapshot(store, "contract-replace-ttl")).toEqual(["a:2", "b:2"]);
|
|
696
661
|
});
|
|
697
662
|
},
|
|
698
663
|
CASE_MS
|
|
@@ -735,37 +700,37 @@ async function walk2(store, collection, params2, between) {
|
|
|
735
700
|
return pages;
|
|
736
701
|
}
|
|
737
702
|
function runDocumentStoreContract(name, make, opts = {}) {
|
|
738
|
-
|
|
739
|
-
|
|
703
|
+
describe4(`DocumentStoreAdapter contract: ${name}`, () => {
|
|
704
|
+
it5(
|
|
740
705
|
"get is null for a missing key; put then get round-trips; a second put overwrites",
|
|
741
706
|
async () => {
|
|
742
707
|
await using2(make, async (store) => {
|
|
743
|
-
|
|
708
|
+
expect5(await store.get("col", "missing")).toBeNull();
|
|
744
709
|
await store.put("col", "k1", { name: "test", value: 42, nested: { list: [1, "two"] } });
|
|
745
|
-
|
|
710
|
+
expect5(await store.get("col", "k1")).toEqual({
|
|
746
711
|
name: "test",
|
|
747
712
|
value: 42,
|
|
748
713
|
nested: { list: [1, "two"] }
|
|
749
714
|
});
|
|
750
715
|
await store.put("col", "k1", { v: 2 });
|
|
751
|
-
|
|
716
|
+
expect5(await store.get("col", "k1")).toEqual({ v: 2 });
|
|
752
717
|
});
|
|
753
718
|
},
|
|
754
719
|
CASE_MS
|
|
755
720
|
);
|
|
756
|
-
|
|
721
|
+
it5(
|
|
757
722
|
"delete removes the document, and deleting a missing key does not throw",
|
|
758
723
|
async () => {
|
|
759
724
|
await using2(make, async (store) => {
|
|
760
725
|
await store.put("col", "k1", { v: 1 });
|
|
761
726
|
await store.delete("col", "k1");
|
|
762
|
-
|
|
763
|
-
await
|
|
727
|
+
expect5(await store.get("col", "k1")).toBeNull();
|
|
728
|
+
await expect5(store.delete("col", "nope")).resolves.toBeUndefined();
|
|
764
729
|
});
|
|
765
730
|
},
|
|
766
731
|
CASE_MS
|
|
767
732
|
);
|
|
768
|
-
|
|
733
|
+
it5(
|
|
769
734
|
"query narrows by partition, and '*' returns the whole collection",
|
|
770
735
|
async () => {
|
|
771
736
|
await using2(make, async (store) => {
|
|
@@ -775,15 +740,15 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
775
740
|
{ id: "c", part: "p" }
|
|
776
741
|
]);
|
|
777
742
|
const p = await store.query("col", { partitionKey: { field: "part", value: "p" } });
|
|
778
|
-
|
|
743
|
+
expect5(ids(p.items).sort()).toEqual(["a", "c"]);
|
|
779
744
|
const all = await store.query("col", { partitionKey: { field: "part", value: "*" } });
|
|
780
|
-
|
|
781
|
-
|
|
745
|
+
expect5(ids(all.items).sort()).toEqual(["a", "b", "c"]);
|
|
746
|
+
expect5(all.nextCursor).toBeNull();
|
|
782
747
|
});
|
|
783
748
|
},
|
|
784
749
|
CASE_MS
|
|
785
750
|
);
|
|
786
|
-
|
|
751
|
+
it5(
|
|
787
752
|
"sortKey eq is strict (1 is not '1', 'a' is not 'A'); gt, lt and between follow compareDocumentValues",
|
|
788
753
|
async () => {
|
|
789
754
|
await using2(make, async (store) => {
|
|
@@ -801,20 +766,20 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
801
766
|
sortKey
|
|
802
767
|
})).items
|
|
803
768
|
).sort();
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
769
|
+
expect5(await where({ field: "v", operator: "eq", value: 1 })).toEqual(["n1"]);
|
|
770
|
+
expect5(await where({ field: "v", operator: "eq", value: "a" })).toEqual(["sa"]);
|
|
771
|
+
expect5(await where({ field: "v", operator: "lt", value: "0" })).toEqual(["n1", "n5"]);
|
|
772
|
+
expect5(await where({ field: "v", operator: "gt", value: 5 })).toEqual(
|
|
808
773
|
["nul", "s1", "sA", "sa"].sort()
|
|
809
774
|
);
|
|
810
|
-
|
|
775
|
+
expect5(await where({ field: "v", operator: "between", value: [2, "a"] })).toEqual(
|
|
811
776
|
["n5", "s1", "sA", "sa"].sort()
|
|
812
777
|
);
|
|
813
778
|
});
|
|
814
779
|
},
|
|
815
780
|
CASE_MS
|
|
816
781
|
);
|
|
817
|
-
|
|
782
|
+
it5(
|
|
818
783
|
"orderBy orders by value with the collator, nulls and missing last, ties by key, on a dotted path too",
|
|
819
784
|
async () => {
|
|
820
785
|
await using2(make, async (store) => {
|
|
@@ -831,17 +796,17 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
831
796
|
partitionKey: { field: "part", value: "p" },
|
|
832
797
|
orderBy: { field: "nested.v", direction: "asc", nulls: "last" }
|
|
833
798
|
});
|
|
834
|
-
|
|
799
|
+
expect5(ids(asc.items)).toEqual(["k6", "k5", "k2", "k1", "k4", "k0", "k3"]);
|
|
835
800
|
const desc = await store.query("col", {
|
|
836
801
|
partitionKey: { field: "part", value: "p" },
|
|
837
802
|
orderBy: { field: "nested.v", direction: "desc", nulls: "last" }
|
|
838
803
|
});
|
|
839
|
-
|
|
804
|
+
expect5(ids(desc.items)).toEqual(["k1", "k4", "k2", "k5", "k6", "k0", "k3"]);
|
|
840
805
|
});
|
|
841
806
|
},
|
|
842
807
|
CASE_MS
|
|
843
808
|
);
|
|
844
|
-
|
|
809
|
+
it5(
|
|
845
810
|
"a limit and cursor walk returns every match exactly once, also when a document is deleted mid-walk",
|
|
846
811
|
async () => {
|
|
847
812
|
await using2(make, async (store) => {
|
|
@@ -854,20 +819,20 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
854
819
|
...orderBy !== void 0 && { orderBy }
|
|
855
820
|
};
|
|
856
821
|
const whole = (await walk2(store, "col", params2)).flat();
|
|
857
|
-
|
|
822
|
+
expect5(ids(whole).sort()).toEqual(ids(docs).sort());
|
|
858
823
|
const anchor = whole[1];
|
|
859
824
|
const walked = (await walk2(store, "col", params2, async (page) => {
|
|
860
825
|
if (page === 1) await store.delete("col", anchor.id);
|
|
861
826
|
})).flat();
|
|
862
|
-
|
|
863
|
-
|
|
827
|
+
expect5(new Set(ids(walked)).size).toBe(walked.length);
|
|
828
|
+
expect5(ids(walked).sort()).toEqual(ids(docs).sort());
|
|
864
829
|
await store.put("col", anchor.id, anchor);
|
|
865
830
|
}
|
|
866
831
|
});
|
|
867
832
|
},
|
|
868
833
|
SLOW3
|
|
869
834
|
);
|
|
870
|
-
|
|
835
|
+
it5(
|
|
871
836
|
"a cursor issued under one orderBy is refused under another, and continues under its own",
|
|
872
837
|
async () => {
|
|
873
838
|
await using2(make, async (store) => {
|
|
@@ -882,25 +847,25 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
882
847
|
limit: 2
|
|
883
848
|
};
|
|
884
849
|
const first = await store.query("col", base);
|
|
885
|
-
|
|
850
|
+
expect5(first.nextCursor).not.toBeNull();
|
|
886
851
|
const cursor = first.nextCursor;
|
|
887
852
|
const other = await rejection2(
|
|
888
853
|
store.query("col", { ...base, orderBy: { field: "v", direction: "desc" }, cursor })
|
|
889
854
|
);
|
|
890
|
-
|
|
855
|
+
expect5(other).toBeInstanceOf(Error);
|
|
891
856
|
const next = await store.query("col", { ...base, cursor });
|
|
892
|
-
|
|
857
|
+
expect5(ids(next.items)).toEqual(["k2", "k3"]);
|
|
893
858
|
});
|
|
894
859
|
},
|
|
895
860
|
CASE_MS
|
|
896
861
|
);
|
|
897
|
-
|
|
862
|
+
it5(
|
|
898
863
|
"a document put with a ttl (seconds) is gone from get, query and batchGet once it expires",
|
|
899
864
|
async () => {
|
|
900
865
|
await using2(make, async (store) => {
|
|
901
866
|
await store.put("col", "short", { id: "short", part: "p" }, { ttl: 1 });
|
|
902
867
|
await store.put("col", "kept", { id: "kept", part: "p" });
|
|
903
|
-
|
|
868
|
+
expect5(await store.get("col", "short")).toEqual({ id: "short", part: "p" });
|
|
904
869
|
if (opts.clock !== void 0) {
|
|
905
870
|
await opts.clock.advance(1001);
|
|
906
871
|
} else {
|
|
@@ -909,15 +874,15 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
909
874
|
await new Promise((r) => setTimeout(r, 50));
|
|
910
875
|
}
|
|
911
876
|
}
|
|
912
|
-
|
|
877
|
+
expect5(await store.get("col", "short")).toBeNull();
|
|
913
878
|
const all = await store.query("col", { partitionKey: { field: "part", value: "*" } });
|
|
914
|
-
|
|
915
|
-
|
|
879
|
+
expect5(ids(all.items)).toEqual(["kept"]);
|
|
880
|
+
expect5([...(await store.batchGet("col", ["short", "kept"])).keys()]).toEqual(["kept"]);
|
|
916
881
|
});
|
|
917
882
|
},
|
|
918
883
|
SLOW3
|
|
919
884
|
);
|
|
920
|
-
|
|
885
|
+
it5(
|
|
921
886
|
"a conditional put applies when the field is strictly equal, and otherwise fails condition-failed",
|
|
922
887
|
async () => {
|
|
923
888
|
await using2(make, async (store) => {
|
|
@@ -928,12 +893,12 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
928
893
|
{ version: 2, label: "v2" },
|
|
929
894
|
{ conditionKey: "version", conditionValue: 1 }
|
|
930
895
|
);
|
|
931
|
-
|
|
896
|
+
expect5(await store.get("col", "c")).toEqual({ version: 2, label: "v2" });
|
|
932
897
|
for (const conditionValue of [1, "2"]) {
|
|
933
898
|
const failed = await rejection2(
|
|
934
899
|
store.put("col", "c", { version: 3 }, { conditionKey: "version", conditionValue })
|
|
935
900
|
);
|
|
936
|
-
|
|
901
|
+
expect5(failed?.code).toBe("adapter.condition-failed");
|
|
937
902
|
}
|
|
938
903
|
const absent = await rejection2(
|
|
939
904
|
store.put(
|
|
@@ -943,14 +908,14 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
943
908
|
{ conditionKey: "version", conditionValue: 1 }
|
|
944
909
|
)
|
|
945
910
|
);
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
911
|
+
expect5(absent?.code).toBe("adapter.condition-failed");
|
|
912
|
+
expect5(await store.get("col", "c")).toEqual({ version: 2, label: "v2" });
|
|
913
|
+
expect5(await store.get("col", "none")).toBeNull();
|
|
949
914
|
});
|
|
950
915
|
},
|
|
951
916
|
CASE_MS
|
|
952
917
|
);
|
|
953
|
-
|
|
918
|
+
it5(
|
|
954
919
|
"batchPut stores every item; batchGet returns exactly the stored keys",
|
|
955
920
|
async () => {
|
|
956
921
|
await using2(make, async (store) => {
|
|
@@ -962,32 +927,32 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
962
927
|
])
|
|
963
928
|
);
|
|
964
929
|
const got = await store.batchGet("col", ["x", "y", "missing"]);
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
930
|
+
expect5([...got.keys()].sort()).toEqual(["x", "y"]);
|
|
931
|
+
expect5(got.get("x")).toEqual({ n: 10 });
|
|
932
|
+
expect5(await store.get("col", "y")).toEqual({ n: 20 });
|
|
933
|
+
expect5((await store.batchGet("col", [])).size).toBe(0);
|
|
969
934
|
});
|
|
970
935
|
},
|
|
971
936
|
CASE_MS
|
|
972
937
|
);
|
|
973
|
-
|
|
938
|
+
it5(
|
|
974
939
|
"collections are isolated for get, query and delete",
|
|
975
940
|
async () => {
|
|
976
941
|
await using2(make, async (store) => {
|
|
977
942
|
await store.put("colA", "key", { id: "key", part: "p", from: "A" });
|
|
978
943
|
await store.put("colB", "key", { id: "key", part: "p", from: "B" });
|
|
979
|
-
|
|
944
|
+
expect5(await store.get("colA", "key")).toEqual({ id: "key", part: "p", from: "A" });
|
|
980
945
|
const inB = await store.query("colB", { partitionKey: { field: "part", value: "*" } });
|
|
981
|
-
|
|
946
|
+
expect5(inB.items).toEqual([{ id: "key", part: "p", from: "B" }]);
|
|
982
947
|
await store.delete("colA", "key");
|
|
983
|
-
|
|
948
|
+
expect5(await store.get("colB", "key")).toEqual({ id: "key", part: "p", from: "B" });
|
|
984
949
|
});
|
|
985
950
|
},
|
|
986
951
|
CASE_MS
|
|
987
952
|
);
|
|
988
953
|
const isolation = opts.isolation;
|
|
989
954
|
if (isolation !== void 0) {
|
|
990
|
-
|
|
955
|
+
it5(
|
|
991
956
|
"two stores under different prefixes never see each other\u2019s documents",
|
|
992
957
|
async () => {
|
|
993
958
|
const [one, two] = await isolation();
|
|
@@ -995,16 +960,16 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
995
960
|
await one.put("col", "shared", { id: "shared", part: "p", owner: "one" });
|
|
996
961
|
await one.put("col", "one-only", { id: "one-only", part: "p", owner: "one" });
|
|
997
962
|
await two.put("col", "shared", { id: "shared", part: "p", owner: "two" });
|
|
998
|
-
|
|
963
|
+
expect5(await one.get("col", "shared")).toEqual({
|
|
999
964
|
id: "shared",
|
|
1000
965
|
part: "p",
|
|
1001
966
|
owner: "one"
|
|
1002
967
|
});
|
|
1003
|
-
|
|
968
|
+
expect5(await two.get("col", "one-only")).toBeNull();
|
|
1004
969
|
const inTwo = await two.query("col", { partitionKey: { field: "part", value: "*" } });
|
|
1005
|
-
|
|
970
|
+
expect5(inTwo.items).toEqual([{ id: "shared", part: "p", owner: "two" }]);
|
|
1006
971
|
await one.delete("col", "shared");
|
|
1007
|
-
|
|
972
|
+
expect5(await two.get("col", "shared")).toEqual({
|
|
1008
973
|
id: "shared",
|
|
1009
974
|
part: "p",
|
|
1010
975
|
owner: "two"
|
|
@@ -1024,28 +989,28 @@ function runDocumentStoreContract(name, make, opts = {}) {
|
|
|
1024
989
|
}
|
|
1025
990
|
|
|
1026
991
|
// src/email.ts
|
|
1027
|
-
import { describe as
|
|
992
|
+
import { describe as describe5, expect as expect6, it as it6 } from "vitest";
|
|
1028
993
|
var MAIL = {
|
|
1029
994
|
template: "order-confirmation",
|
|
1030
995
|
to: "test@example.com",
|
|
1031
996
|
vars: { orderNumber: "ORD-001" }
|
|
1032
997
|
};
|
|
1033
998
|
function runEmailContract(name, build, options = {}) {
|
|
1034
|
-
|
|
1035
|
-
|
|
999
|
+
describe5(`EmailAdapter contract: ${name}`, () => {
|
|
1000
|
+
it6(
|
|
1036
1001
|
"sendTransactional resolves with accepted or messageId",
|
|
1037
1002
|
async () => {
|
|
1038
1003
|
const adapter = await build();
|
|
1039
1004
|
const result = await adapter.sendTransactional(MAIL);
|
|
1040
|
-
|
|
1005
|
+
expect6(typeof result.accepted).toBe("boolean");
|
|
1041
1006
|
},
|
|
1042
1007
|
CASE_MS
|
|
1043
1008
|
);
|
|
1044
|
-
|
|
1009
|
+
it6(
|
|
1045
1010
|
"syncCustomerToList resolves without throwing",
|
|
1046
1011
|
async () => {
|
|
1047
1012
|
const adapter = await build();
|
|
1048
|
-
await
|
|
1013
|
+
await expect6(
|
|
1049
1014
|
adapter.syncCustomerToList({
|
|
1050
1015
|
customerId: "cust-1",
|
|
1051
1016
|
orderCount: 0,
|
|
@@ -1057,11 +1022,11 @@ function runEmailContract(name, build, options = {}) {
|
|
|
1057
1022
|
},
|
|
1058
1023
|
CASE_MS
|
|
1059
1024
|
);
|
|
1060
|
-
|
|
1025
|
+
it6(
|
|
1061
1026
|
"removeCustomerFromList resolves without throwing",
|
|
1062
1027
|
async () => {
|
|
1063
1028
|
const adapter = await build();
|
|
1064
|
-
await
|
|
1029
|
+
await expect6(adapter.removeCustomerFromList("cust-1")).resolves.not.toThrow();
|
|
1065
1030
|
},
|
|
1066
1031
|
CASE_MS
|
|
1067
1032
|
);
|
|
@@ -1075,13 +1040,13 @@ function runEmailContract(name, build, options = {}) {
|
|
|
1075
1040
|
}
|
|
1076
1041
|
|
|
1077
1042
|
// src/event-bus.ts
|
|
1078
|
-
import { randomUUID as
|
|
1043
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1079
1044
|
import { describe as describe8, expect as expect9, it as it9 } from "vitest";
|
|
1080
1045
|
|
|
1081
1046
|
// src/event-bus-delivery.ts
|
|
1082
1047
|
import { randomUUID } from "crypto";
|
|
1083
1048
|
import fc3 from "fast-check";
|
|
1084
|
-
import { describe as
|
|
1049
|
+
import { describe as describe6, expect as expect7, it as it7 } from "vitest";
|
|
1085
1050
|
var SLOW4 = 6e4;
|
|
1086
1051
|
function event(type, payload = {}) {
|
|
1087
1052
|
return {
|
|
@@ -1118,8 +1083,8 @@ async function closingAll(buses, body) {
|
|
|
1118
1083
|
}
|
|
1119
1084
|
var TYPES = ["order.created.v1", "order.paid.v1", "cart.updated.v1"];
|
|
1120
1085
|
function eventBusDeliveryCases(make, opts) {
|
|
1121
|
-
|
|
1122
|
-
|
|
1086
|
+
describe6("keeps the delivery contract", () => {
|
|
1087
|
+
it7(
|
|
1123
1088
|
"publish resolves only after every matching handler has finished: a 200 ms handler, named and unnamed, is done first",
|
|
1124
1089
|
async () => {
|
|
1125
1090
|
const bus = await make();
|
|
@@ -1140,14 +1105,14 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1140
1105
|
const startedAt = Date.now();
|
|
1141
1106
|
await bus.publish(event("contract.slow.v1"));
|
|
1142
1107
|
const elapsed = Date.now() - startedAt;
|
|
1143
|
-
|
|
1144
|
-
|
|
1108
|
+
expect7([...finished].sort()).toEqual(["named", "unnamed"]);
|
|
1109
|
+
expect7(elapsed).toBeGreaterThanOrEqual(190);
|
|
1145
1110
|
});
|
|
1146
1111
|
},
|
|
1147
1112
|
SLOW4
|
|
1148
1113
|
);
|
|
1149
|
-
|
|
1150
|
-
"a throwing handler
|
|
1114
|
+
it7(
|
|
1115
|
+
"a throwing named handler rejects publish with handler_failed naming it, and every other matching handler is called, event after event",
|
|
1151
1116
|
async () => {
|
|
1152
1117
|
const bus = await make();
|
|
1153
1118
|
await closingAll([bus], async () => {
|
|
@@ -1162,14 +1127,23 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1162
1127
|
await bus.subscribe("contract.boom.v1", async (e) => {
|
|
1163
1128
|
received.push(e.id);
|
|
1164
1129
|
});
|
|
1165
|
-
const events = [
|
|
1166
|
-
|
|
1167
|
-
|
|
1130
|
+
const events = [
|
|
1131
|
+
event("contract.boom.v1"),
|
|
1132
|
+
event("contract.boom.v1"),
|
|
1133
|
+
event("contract.boom.v1")
|
|
1134
|
+
];
|
|
1135
|
+
for (const e of events) {
|
|
1136
|
+
await expect7(bus.publish(e)).rejects.toMatchObject({
|
|
1137
|
+
code: "adapter.handler_failed",
|
|
1138
|
+
context: { eventId: e.id, failed: [{ subscription: "contract-throws" }] }
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
expect7(received).toEqual(events.map((e) => e.id));
|
|
1168
1142
|
});
|
|
1169
1143
|
},
|
|
1170
1144
|
SLOW4
|
|
1171
1145
|
);
|
|
1172
|
-
|
|
1146
|
+
it7(
|
|
1173
1147
|
"replay reaches derived subscriptions only, in order; a later publish still reaches every subscription",
|
|
1174
1148
|
async () => {
|
|
1175
1149
|
const bus = await make();
|
|
@@ -1187,19 +1161,19 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1187
1161
|
await bus.subscribe("contract.replay.v1", async (e) => void sideUnnamed.push(e.id));
|
|
1188
1162
|
const replayed = [event("contract.replay.v1"), event("contract.replay.v1")];
|
|
1189
1163
|
await bus.replay(replayed);
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1164
|
+
expect7(derived).toEqual(replayed.map((e) => e.id));
|
|
1165
|
+
expect7(sideNamed).toEqual([]);
|
|
1166
|
+
expect7(sideUnnamed).toEqual([]);
|
|
1193
1167
|
const live = event("contract.replay.v1");
|
|
1194
1168
|
await bus.publish(live);
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1169
|
+
expect7(derived).toEqual([...replayed, live].map((e) => e.id));
|
|
1170
|
+
expect7(sideNamed).toEqual([live.id]);
|
|
1171
|
+
expect7(sideUnnamed).toEqual([live.id]);
|
|
1198
1172
|
});
|
|
1199
1173
|
},
|
|
1200
1174
|
SLOW4
|
|
1201
1175
|
);
|
|
1202
|
-
|
|
1176
|
+
it7(
|
|
1203
1177
|
"publishBatch reaches each subscription with exactly its matching events, in batch order (wildcards and pattern arrays)",
|
|
1204
1178
|
async () => {
|
|
1205
1179
|
await fc3.assert(
|
|
@@ -1211,7 +1185,10 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1211
1185
|
const fence = event("contract.fence.v1");
|
|
1212
1186
|
const subs = [
|
|
1213
1187
|
{ patterns: ["order.*", fence.type], name: void 0 },
|
|
1214
|
-
{
|
|
1188
|
+
{
|
|
1189
|
+
patterns: ["cart.updated.v1", "order.paid.v1", fence.type],
|
|
1190
|
+
name: "contract-arr"
|
|
1191
|
+
},
|
|
1215
1192
|
{ patterns: ["order.created.v1", fence.type], name: "contract-one" }
|
|
1216
1193
|
];
|
|
1217
1194
|
const seen = subs.map(() => []);
|
|
@@ -1230,7 +1207,7 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1230
1207
|
);
|
|
1231
1208
|
for (const [i, sub] of subs.entries()) {
|
|
1232
1209
|
const expected = events.filter((e) => matches(sub.patterns, e.type));
|
|
1233
|
-
|
|
1210
|
+
expect7(seen[i], sub.patterns.join("|")).toEqual([
|
|
1234
1211
|
...expected.map((e) => e.id),
|
|
1235
1212
|
fence.id
|
|
1236
1213
|
]);
|
|
@@ -1243,7 +1220,7 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1243
1220
|
},
|
|
1244
1221
|
SLOW4
|
|
1245
1222
|
);
|
|
1246
|
-
|
|
1223
|
+
it7(
|
|
1247
1224
|
"an unsubscribed handler, by bus.unsubscribe or by its own unsubscribe(), receives nothing more",
|
|
1248
1225
|
async () => {
|
|
1249
1226
|
const bus = await make();
|
|
@@ -1262,14 +1239,14 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1262
1239
|
await b.unsubscribe();
|
|
1263
1240
|
const after = [event("contract.unsub.v1"), event("contract.unsub.v1")];
|
|
1264
1241
|
for (const e of after) await bus.publish(e);
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1242
|
+
expect7(kept).toEqual([before, ...after].map((e) => e.id));
|
|
1243
|
+
expect7(gone).toEqual([before.id]);
|
|
1244
|
+
expect7(goneToo).toEqual([before.id]);
|
|
1268
1245
|
});
|
|
1269
1246
|
},
|
|
1270
1247
|
SLOW4
|
|
1271
1248
|
);
|
|
1272
|
-
|
|
1249
|
+
it7(
|
|
1273
1250
|
"on one instance, a named and an unnamed subscription each handle every event once",
|
|
1274
1251
|
async () => {
|
|
1275
1252
|
const bus = await make();
|
|
@@ -1282,17 +1259,17 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1282
1259
|
await bus.subscribe("contract.both.v1", async (e) => void unnamed.push(e.id));
|
|
1283
1260
|
const events = Array.from({ length: 6 }, () => event("contract.both.v1"));
|
|
1284
1261
|
for (const e of events) await bus.publish(e);
|
|
1285
|
-
|
|
1286
|
-
|
|
1262
|
+
expect7(named).toEqual(events.map((e) => e.id));
|
|
1263
|
+
expect7(unnamed).toEqual(named);
|
|
1287
1264
|
});
|
|
1288
1265
|
},
|
|
1289
1266
|
SLOW4
|
|
1290
1267
|
);
|
|
1291
1268
|
const second = opts.second;
|
|
1292
1269
|
if (second === void 0) return;
|
|
1293
|
-
|
|
1270
|
+
describe6("two instances on the same streams", () => {
|
|
1294
1271
|
const N = 12;
|
|
1295
|
-
|
|
1272
|
+
it7(
|
|
1296
1273
|
"one named subscription on both instances handles each of N events exactly once in total",
|
|
1297
1274
|
async () => {
|
|
1298
1275
|
const a = await make();
|
|
@@ -1312,13 +1289,13 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1312
1289
|
await a.publish(event("contract.named.fence"));
|
|
1313
1290
|
const ids2 = new Set(events.map((e) => e.id));
|
|
1314
1291
|
const handled = [...byA, ...byB].filter((id) => ids2.has(id));
|
|
1315
|
-
|
|
1316
|
-
|
|
1292
|
+
expect7(handled.length).toBe(events.length);
|
|
1293
|
+
expect7([...handled].sort()).toEqual(events.map((e) => e.id).sort());
|
|
1317
1294
|
});
|
|
1318
1295
|
},
|
|
1319
1296
|
SLOW4
|
|
1320
1297
|
);
|
|
1321
|
-
|
|
1298
|
+
it7(
|
|
1322
1299
|
"an unnamed subscription on each instance receives all N events, in order, on both",
|
|
1323
1300
|
async () => {
|
|
1324
1301
|
const a = await make();
|
|
@@ -1328,19 +1305,22 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1328
1305
|
const onB = [];
|
|
1329
1306
|
await a.subscribe("contract.fanout.*", async (e) => void onA.push(e.id));
|
|
1330
1307
|
await b.subscribe("contract.fanout.*", async (e) => void onB.push(e.id));
|
|
1331
|
-
const events = Array.from(
|
|
1308
|
+
const events = Array.from(
|
|
1309
|
+
{ length: N },
|
|
1310
|
+
(_, seq) => event("contract.fanout.x", { seq })
|
|
1311
|
+
);
|
|
1332
1312
|
for (const e of events) await a.publish(e);
|
|
1333
1313
|
const fenceB = event("contract.fanout.fence");
|
|
1334
1314
|
await b.publish(fenceB);
|
|
1335
1315
|
const ids2 = events.map((e) => e.id);
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1316
|
+
expect7(onA.slice(0, N)).toEqual(ids2);
|
|
1317
|
+
expect7(onB.slice(0, N)).toEqual(ids2);
|
|
1318
|
+
expect7(onB[N]).toBe(fenceB.id);
|
|
1339
1319
|
});
|
|
1340
1320
|
},
|
|
1341
1321
|
SLOW4
|
|
1342
1322
|
);
|
|
1343
|
-
|
|
1323
|
+
it7(
|
|
1344
1324
|
"replay on one instance reaches its own derived handlers and nothing on the other instance",
|
|
1345
1325
|
async () => {
|
|
1346
1326
|
const a = await make();
|
|
@@ -1364,10 +1344,10 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1364
1344
|
await a.replay(replayed);
|
|
1365
1345
|
const fence = event("contract.rebuild.fence");
|
|
1366
1346
|
await b.publish(fence);
|
|
1367
|
-
|
|
1347
|
+
expect7(derivedA.slice(0, replayed.length)).toEqual(replayed.map((e) => e.id));
|
|
1368
1348
|
const ids2 = new Set(replayed.map((e) => e.id));
|
|
1369
|
-
|
|
1370
|
-
|
|
1349
|
+
expect7(derivedB.filter((id) => ids2.has(id))).toEqual([]);
|
|
1350
|
+
expect7(sideB).toEqual([fence.id]);
|
|
1371
1351
|
});
|
|
1372
1352
|
},
|
|
1373
1353
|
SLOW4
|
|
@@ -1375,13 +1355,15 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1375
1355
|
const timing = opts.timing;
|
|
1376
1356
|
if (timing === void 0) return;
|
|
1377
1357
|
const { claimIdleMs, ackTimeoutMs } = timing;
|
|
1378
|
-
|
|
1358
|
+
it7(
|
|
1379
1359
|
"groups left by a stopped instance (never unsubscribed) do not delay publish on the other instance",
|
|
1380
1360
|
async () => {
|
|
1381
1361
|
const a = await make();
|
|
1382
1362
|
const b = await second();
|
|
1383
1363
|
await closingAll([a], async () => {
|
|
1384
|
-
await b.subscribe("contract.left.*", async () => void 0, {
|
|
1364
|
+
await b.subscribe("contract.left.*", async () => void 0, {
|
|
1365
|
+
name: "contract-left-behind"
|
|
1366
|
+
});
|
|
1385
1367
|
await b.subscribe("contract.left.*", async () => void 0);
|
|
1386
1368
|
await b.close();
|
|
1387
1369
|
const handled = [];
|
|
@@ -1392,13 +1374,13 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1392
1374
|
const startedAt = Date.now();
|
|
1393
1375
|
await a.publish(e);
|
|
1394
1376
|
const elapsed = Date.now() - startedAt;
|
|
1395
|
-
|
|
1396
|
-
|
|
1377
|
+
expect7(handled).toEqual([e.id]);
|
|
1378
|
+
expect7(elapsed).toBeLessThan(ackTimeoutMs / 2);
|
|
1397
1379
|
});
|
|
1398
1380
|
},
|
|
1399
1381
|
SLOW4
|
|
1400
1382
|
);
|
|
1401
|
-
|
|
1383
|
+
it7(
|
|
1402
1384
|
"an entry pending on a stopped consumer of a named group is handled by the live one after claimIdleMs and before ackTimeoutMs",
|
|
1403
1385
|
async () => {
|
|
1404
1386
|
const a = await make();
|
|
@@ -1438,10 +1420,10 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1438
1420
|
);
|
|
1439
1421
|
const claimed = byA.find((h) => h.id === stranded.id);
|
|
1440
1422
|
const idle = claimed.at - receivedByB;
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1423
|
+
expect7(idle, "taken over before claimIdleMs").toBeGreaterThanOrEqual(claimIdleMs - 50);
|
|
1424
|
+
expect7(idle, "taken over only after ackTimeoutMs").toBeLessThan(ackTimeoutMs);
|
|
1425
|
+
expect7(byA.filter((h) => h.id === stranded.id).length).toBe(1);
|
|
1426
|
+
expect7(publishMs, "a publish on the group waited out the timeout").toBeLessThan(
|
|
1445
1427
|
ackTimeoutMs
|
|
1446
1428
|
);
|
|
1447
1429
|
} finally {
|
|
@@ -1452,7 +1434,7 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1452
1434
|
},
|
|
1453
1435
|
SLOW4
|
|
1454
1436
|
);
|
|
1455
|
-
|
|
1437
|
+
it7(
|
|
1456
1438
|
"an unnamed subscription unsubscribed while publish waits on it lets publish resolve well before ackTimeoutMs",
|
|
1457
1439
|
async () => {
|
|
1458
1440
|
const a = await make();
|
|
@@ -1471,8 +1453,8 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1471
1453
|
const unsubscribedAt = Date.now();
|
|
1472
1454
|
const unsubscribing = a.unsubscribe(sub);
|
|
1473
1455
|
const outcome = await publishing;
|
|
1474
|
-
|
|
1475
|
-
|
|
1456
|
+
expect7(typeof outcome, `publish rejected: ${String(outcome)}`).toBe("number");
|
|
1457
|
+
expect7(outcome - unsubscribedAt).toBeLessThan(ackTimeoutMs / 2);
|
|
1476
1458
|
held.open();
|
|
1477
1459
|
await unsubscribing;
|
|
1478
1460
|
} finally {
|
|
@@ -1486,20 +1468,355 @@ function eventBusDeliveryCases(make, opts) {
|
|
|
1486
1468
|
});
|
|
1487
1469
|
}
|
|
1488
1470
|
|
|
1471
|
+
// src/event-bus-failure.ts
|
|
1472
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1473
|
+
import { describe as describe7, expect as expect8, it as it8 } from "vitest";
|
|
1474
|
+
var SLOW5 = 6e4;
|
|
1475
|
+
var SLOW_HANDLER_MS = 200;
|
|
1476
|
+
var IN_PROCESS_WINDOW_MS = 200;
|
|
1477
|
+
function event2(type, payload = {}) {
|
|
1478
|
+
return {
|
|
1479
|
+
id: `evt-${randomUUID2()}`,
|
|
1480
|
+
type,
|
|
1481
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1482
|
+
source: "contract-failure",
|
|
1483
|
+
correlationId: `corr-${randomUUID2()}`,
|
|
1484
|
+
payload,
|
|
1485
|
+
metadata: { storeId: "contract", environment: "development", version: "0.0.0" }
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
var delay2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1489
|
+
async function until2(check, label, ms) {
|
|
1490
|
+
const deadline = Date.now() + ms;
|
|
1491
|
+
while (!check()) {
|
|
1492
|
+
if (Date.now() > deadline) throw new Error(`timed out after ${ms} ms waiting for ${label}`);
|
|
1493
|
+
await delay2(10);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
function gate2() {
|
|
1497
|
+
let open;
|
|
1498
|
+
const wait = new Promise((r) => {
|
|
1499
|
+
open = r;
|
|
1500
|
+
});
|
|
1501
|
+
return { wait, open };
|
|
1502
|
+
}
|
|
1503
|
+
async function closing2(buses, body) {
|
|
1504
|
+
try {
|
|
1505
|
+
await body();
|
|
1506
|
+
} finally {
|
|
1507
|
+
for (const bus of buses) await bus.close();
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async function settled(promise) {
|
|
1511
|
+
const started = Date.now();
|
|
1512
|
+
try {
|
|
1513
|
+
await promise;
|
|
1514
|
+
return { state: "resolved", error: void 0, at: Date.now(), ms: Date.now() - started };
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
return { state: "rejected", error, at: Date.now(), ms: Date.now() - started };
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
function failure(outcome) {
|
|
1520
|
+
expect8(outcome.state, "publish resolved, so the failure was swallowed").toBe("rejected");
|
|
1521
|
+
const e = outcome.error;
|
|
1522
|
+
return {
|
|
1523
|
+
name: String(e.name),
|
|
1524
|
+
code: String(e.code),
|
|
1525
|
+
message: String(e.message),
|
|
1526
|
+
context: e.context ?? {}
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
var failedNames = (f) => (f.context.failed ?? []).map((x) => x.subscription);
|
|
1530
|
+
function eventBusFailureCases(make, opts) {
|
|
1531
|
+
const window = opts.timing === void 0 ? IN_PROCESS_WINDOW_MS : 3 * opts.timing.claimIdleMs;
|
|
1532
|
+
describe7("reports handler failures", () => {
|
|
1533
|
+
it8(
|
|
1534
|
+
"a named handler that throws rejects publish with handler_failed naming it alone, only after the other named handlers (one of them slow) have each run once and finished; a throwing unnamed handler is named nowhere; replay past a throwing derived handler resolves",
|
|
1535
|
+
async () => {
|
|
1536
|
+
const bus = await make();
|
|
1537
|
+
await closing2([bus], async () => {
|
|
1538
|
+
const type = "contract.failure.v1";
|
|
1539
|
+
const ran = { a: 0, b: 0, c: 0 };
|
|
1540
|
+
const finished = { b: void 0, c: void 0 };
|
|
1541
|
+
await bus.subscribe(
|
|
1542
|
+
type,
|
|
1543
|
+
async () => {
|
|
1544
|
+
ran.a += 1;
|
|
1545
|
+
throw new Error("contract handler A refused");
|
|
1546
|
+
},
|
|
1547
|
+
{ name: "contract-failure-a" }
|
|
1548
|
+
);
|
|
1549
|
+
await bus.subscribe(
|
|
1550
|
+
type,
|
|
1551
|
+
async () => {
|
|
1552
|
+
ran.b += 1;
|
|
1553
|
+
await delay2(SLOW_HANDLER_MS);
|
|
1554
|
+
finished.b = Date.now();
|
|
1555
|
+
},
|
|
1556
|
+
{ name: "contract-failure-b" }
|
|
1557
|
+
);
|
|
1558
|
+
await bus.subscribe(
|
|
1559
|
+
type,
|
|
1560
|
+
async () => {
|
|
1561
|
+
ran.c += 1;
|
|
1562
|
+
finished.c = Date.now();
|
|
1563
|
+
},
|
|
1564
|
+
{ name: "contract-failure-c" }
|
|
1565
|
+
);
|
|
1566
|
+
await bus.subscribe(type, async () => {
|
|
1567
|
+
throw new Error("contract admin tab refused");
|
|
1568
|
+
});
|
|
1569
|
+
const e = event2(type);
|
|
1570
|
+
const outcome = await settled(bus.publish(e));
|
|
1571
|
+
expect8(ran).toEqual({ a: 1, b: 1, c: 1 });
|
|
1572
|
+
expect8(finished.b, "the slow handler finished before the rejection").toBeLessThanOrEqual(
|
|
1573
|
+
outcome.at
|
|
1574
|
+
);
|
|
1575
|
+
expect8(finished.c).toBeLessThanOrEqual(outcome.at);
|
|
1576
|
+
expect8(outcome.ms).toBeGreaterThanOrEqual(SLOW_HANDLER_MS - 10);
|
|
1577
|
+
const rebuilt = [];
|
|
1578
|
+
await bus.subscribe(
|
|
1579
|
+
"contract.failure-replay.v1",
|
|
1580
|
+
async () => {
|
|
1581
|
+
throw new Error("contract projection refused");
|
|
1582
|
+
},
|
|
1583
|
+
{ name: "contract-failure-projection-a", derived: true }
|
|
1584
|
+
);
|
|
1585
|
+
await bus.subscribe("contract.failure-replay.v1", async (r) => void rebuilt.push(r.id), {
|
|
1586
|
+
name: "contract-failure-projection-b",
|
|
1587
|
+
derived: true
|
|
1588
|
+
});
|
|
1589
|
+
const replayed = [
|
|
1590
|
+
event2("contract.failure-replay.v1"),
|
|
1591
|
+
event2("contract.failure-replay.v1")
|
|
1592
|
+
];
|
|
1593
|
+
const replay = await settled(bus.replay(replayed));
|
|
1594
|
+
expect8(replay.state).toBe("resolved");
|
|
1595
|
+
expect8(rebuilt).toEqual(replayed.map((r) => r.id));
|
|
1596
|
+
const f = failure(outcome);
|
|
1597
|
+
expect8([f.name, f.code]).toEqual(["AdapterError", "adapter.handler_failed"]);
|
|
1598
|
+
expect8([f.context.eventId, f.context.eventType]).toEqual([e.id, e.type]);
|
|
1599
|
+
expect8(failedNames(f)).toEqual(["contract-failure-a"]);
|
|
1600
|
+
expect8(f.context.failed?.[0]?.message).toContain("contract handler A refused");
|
|
1601
|
+
});
|
|
1602
|
+
},
|
|
1603
|
+
SLOW5
|
|
1604
|
+
);
|
|
1605
|
+
it8(
|
|
1606
|
+
"publishBatch of three events whose second fails a named handler hands all three, in order, to every subscription and only then rejects with handler_failed naming that event and subscription",
|
|
1607
|
+
async () => {
|
|
1608
|
+
const bus = await make();
|
|
1609
|
+
await closing2([bus], async () => {
|
|
1610
|
+
const type = "contract.failure-batch.v1";
|
|
1611
|
+
const picky = [];
|
|
1612
|
+
const other = [];
|
|
1613
|
+
const unnamed = [];
|
|
1614
|
+
await bus.subscribe(
|
|
1615
|
+
type,
|
|
1616
|
+
async (e) => {
|
|
1617
|
+
picky.push(e.id);
|
|
1618
|
+
if (e.payload.seq === 1)
|
|
1619
|
+
throw new Error("contract picky refused");
|
|
1620
|
+
},
|
|
1621
|
+
{ name: "contract-failure-picky" }
|
|
1622
|
+
);
|
|
1623
|
+
await bus.subscribe(
|
|
1624
|
+
type,
|
|
1625
|
+
async (e) => {
|
|
1626
|
+
await delay2(50);
|
|
1627
|
+
other.push(e.id);
|
|
1628
|
+
},
|
|
1629
|
+
{ name: "contract-failure-other" }
|
|
1630
|
+
);
|
|
1631
|
+
await bus.subscribe(type, async (e) => {
|
|
1632
|
+
await delay2(50);
|
|
1633
|
+
unnamed.push(e.id);
|
|
1634
|
+
});
|
|
1635
|
+
const events = [0, 1, 2].map((seq) => event2(type, { seq }));
|
|
1636
|
+
const seenAtRejection = [];
|
|
1637
|
+
const outcome = await settled(
|
|
1638
|
+
bus.publishBatch(events).catch((error) => {
|
|
1639
|
+
seenAtRejection.push([...picky], [...other], [...unnamed]);
|
|
1640
|
+
throw error;
|
|
1641
|
+
})
|
|
1642
|
+
);
|
|
1643
|
+
const f = failure(outcome);
|
|
1644
|
+
expect8(f.code).toBe("adapter.handler_failed");
|
|
1645
|
+
expect8(f.context.eventId).toBe(events[1].id);
|
|
1646
|
+
expect8(failedNames(f)).toEqual(["contract-failure-picky"]);
|
|
1647
|
+
const ids2 = events.map((e) => e.id);
|
|
1648
|
+
expect8(
|
|
1649
|
+
seenAtRejection,
|
|
1650
|
+
"what each subscription had been handed when the batch rejected"
|
|
1651
|
+
).toEqual([ids2, ids2, ids2]);
|
|
1652
|
+
});
|
|
1653
|
+
},
|
|
1654
|
+
SLOW5
|
|
1655
|
+
);
|
|
1656
|
+
it8(
|
|
1657
|
+
"publishing a rejected event again reaches every subscription again and resolves once the failed handler passes; in the window after the rejection the bus ran the failed handler only once per publish",
|
|
1658
|
+
async () => {
|
|
1659
|
+
const bus = await make();
|
|
1660
|
+
await closing2([bus], async () => {
|
|
1661
|
+
const type = "contract.failure-again.v1";
|
|
1662
|
+
const ran = { a: 0, b: 0, c: 0, unnamed: 0 };
|
|
1663
|
+
await bus.subscribe(
|
|
1664
|
+
type,
|
|
1665
|
+
async () => {
|
|
1666
|
+
ran.a += 1;
|
|
1667
|
+
if (ran.a === 1) throw new Error("contract handler A refused once");
|
|
1668
|
+
},
|
|
1669
|
+
{ name: "contract-again-a" }
|
|
1670
|
+
);
|
|
1671
|
+
await bus.subscribe(type, async () => void (ran.b += 1), { name: "contract-again-b" });
|
|
1672
|
+
await bus.subscribe(type, async () => void (ran.c += 1), { name: "contract-again-c" });
|
|
1673
|
+
await bus.subscribe(type, async () => void (ran.unnamed += 1));
|
|
1674
|
+
const e = event2(type);
|
|
1675
|
+
const first = await settled(bus.publish(e));
|
|
1676
|
+
expect8(failure(first).code).toBe("adapter.handler_failed");
|
|
1677
|
+
await delay2(window);
|
|
1678
|
+
const afterWindow = { ...ran };
|
|
1679
|
+
expect8(afterWindow.a, "the failed handler ran again without a publish").toBe(1);
|
|
1680
|
+
const second2 = await settled(bus.publish(e));
|
|
1681
|
+
expect8(second2.state, String(second2.error)).toBe("resolved");
|
|
1682
|
+
expect8(ran.a).toBe(2);
|
|
1683
|
+
expect8(ran.b).toBe(2);
|
|
1684
|
+
expect8(ran.c).toBe(ran.b);
|
|
1685
|
+
expect8(ran.unnamed).toBe(ran.b);
|
|
1686
|
+
expect8(ran.b - afterWindow.b, "the second publish reached B again").toBe(1);
|
|
1687
|
+
});
|
|
1688
|
+
},
|
|
1689
|
+
SLOW5
|
|
1690
|
+
);
|
|
1691
|
+
const second = opts.second;
|
|
1692
|
+
const timing = opts.timing;
|
|
1693
|
+
if (second === void 0 || timing === void 0) return;
|
|
1694
|
+
it8(
|
|
1695
|
+
"a named handler that throws on another instance fails the publish on this one with handler_failed, not publish_timeout, well within ackTimeoutMs",
|
|
1696
|
+
async () => {
|
|
1697
|
+
const a = await make();
|
|
1698
|
+
const b = await second();
|
|
1699
|
+
const held = gate2();
|
|
1700
|
+
let holding;
|
|
1701
|
+
try {
|
|
1702
|
+
const group = "contract-remote";
|
|
1703
|
+
const hold = "contract.remote.hold";
|
|
1704
|
+
const target = "contract.remote.target";
|
|
1705
|
+
const ranOn = {};
|
|
1706
|
+
const note = (id, where) => (ranOn[id] ??= []).push(where);
|
|
1707
|
+
let aHolding = false;
|
|
1708
|
+
await a.subscribe(
|
|
1709
|
+
"contract.remote.*",
|
|
1710
|
+
async (e2) => {
|
|
1711
|
+
note(e2.id, "a");
|
|
1712
|
+
if (e2.type === hold) {
|
|
1713
|
+
aHolding = true;
|
|
1714
|
+
await held.wait;
|
|
1715
|
+
}
|
|
1716
|
+
},
|
|
1717
|
+
{ name: group }
|
|
1718
|
+
);
|
|
1719
|
+
holding = settled(a.publish(event2(hold)));
|
|
1720
|
+
await until2(() => aHolding, "a to take the holding entry", timing.ackTimeoutMs);
|
|
1721
|
+
await b.subscribe(
|
|
1722
|
+
"contract.remote.*",
|
|
1723
|
+
async (e2) => {
|
|
1724
|
+
if (e2.type === hold) return;
|
|
1725
|
+
note(e2.id, "b");
|
|
1726
|
+
throw new Error("contract remote handler refused");
|
|
1727
|
+
},
|
|
1728
|
+
{ name: group }
|
|
1729
|
+
);
|
|
1730
|
+
const e = event2(target);
|
|
1731
|
+
const outcome = await settled(a.publish(e));
|
|
1732
|
+
expect8(ranOn[e.id], "the failing handler ran on the second instance only").toEqual(["b"]);
|
|
1733
|
+
const f = failure(outcome);
|
|
1734
|
+
expect8(f.code, f.message).toBe("adapter.handler_failed");
|
|
1735
|
+
expect8(failedNames(f)).toEqual([group]);
|
|
1736
|
+
expect8(outcome.ms).toBeLessThan(timing.ackTimeoutMs / 2);
|
|
1737
|
+
} finally {
|
|
1738
|
+
held.open();
|
|
1739
|
+
await holding;
|
|
1740
|
+
await a.close();
|
|
1741
|
+
await b.close();
|
|
1742
|
+
}
|
|
1743
|
+
},
|
|
1744
|
+
SLOW5
|
|
1745
|
+
);
|
|
1746
|
+
it8(
|
|
1747
|
+
"a named handler that fails once on another instance, then passes on the second publish within ackTimeoutMs: the first publish rejects and the second resolves, and the handler ran twice",
|
|
1748
|
+
async () => {
|
|
1749
|
+
const a = await make();
|
|
1750
|
+
const b = await second();
|
|
1751
|
+
const held = gate2();
|
|
1752
|
+
let holding;
|
|
1753
|
+
try {
|
|
1754
|
+
const group = "contract-remote-once";
|
|
1755
|
+
const hold = "contract.remote-once.hold";
|
|
1756
|
+
const target = "contract.remote-once.target";
|
|
1757
|
+
const ranOn = {};
|
|
1758
|
+
const note = (id, where) => (ranOn[id] ??= []).push(where);
|
|
1759
|
+
let aHolding = false;
|
|
1760
|
+
await a.subscribe(
|
|
1761
|
+
"contract.remote-once.*",
|
|
1762
|
+
async (e2) => {
|
|
1763
|
+
note(e2.id, "a");
|
|
1764
|
+
if (e2.type === hold) {
|
|
1765
|
+
aHolding = true;
|
|
1766
|
+
await held.wait;
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
{ name: group }
|
|
1770
|
+
);
|
|
1771
|
+
holding = settled(a.publish(event2(hold)));
|
|
1772
|
+
await until2(() => aHolding, "a to take the holding entry", timing.ackTimeoutMs);
|
|
1773
|
+
let runs = 0;
|
|
1774
|
+
await b.subscribe(
|
|
1775
|
+
"contract.remote-once.*",
|
|
1776
|
+
async (e2) => {
|
|
1777
|
+
if (e2.type === hold) return;
|
|
1778
|
+
note(e2.id, "b");
|
|
1779
|
+
runs += 1;
|
|
1780
|
+
if (runs === 1) throw new Error("contract remote handler refused once");
|
|
1781
|
+
},
|
|
1782
|
+
{ name: group }
|
|
1783
|
+
);
|
|
1784
|
+
const e = event2(target);
|
|
1785
|
+
const first = await settled(a.publish(e));
|
|
1786
|
+
const again = await settled(a.publish(e));
|
|
1787
|
+
expect8(ranOn[e.id], "the handler ran on the second instance, once per publish").toEqual([
|
|
1788
|
+
"b",
|
|
1789
|
+
"b"
|
|
1790
|
+
]);
|
|
1791
|
+
expect8(failure(first).code).toBe("adapter.handler_failed");
|
|
1792
|
+
expect8(again.at - first.at).toBeLessThan(timing.ackTimeoutMs);
|
|
1793
|
+
expect8(again.state, String(again.error)).toBe("resolved");
|
|
1794
|
+
} finally {
|
|
1795
|
+
held.open();
|
|
1796
|
+
await holding;
|
|
1797
|
+
await a.close();
|
|
1798
|
+
await b.close();
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
SLOW5
|
|
1802
|
+
);
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1489
1806
|
// src/event-bus.ts
|
|
1490
|
-
var
|
|
1807
|
+
var SLOW6 = 3e4;
|
|
1491
1808
|
function makeEvent(type, payload = {}) {
|
|
1492
1809
|
return {
|
|
1493
|
-
id: `evt-${
|
|
1810
|
+
id: `evt-${randomUUID3()}`,
|
|
1494
1811
|
type,
|
|
1495
1812
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1496
1813
|
source: "contract-tests",
|
|
1497
|
-
correlationId: `corr-${
|
|
1814
|
+
correlationId: `corr-${randomUUID3()}`,
|
|
1498
1815
|
payload,
|
|
1499
1816
|
metadata: { storeId: "test", environment: "development", version: "0.0.0" }
|
|
1500
1817
|
};
|
|
1501
1818
|
}
|
|
1502
|
-
var
|
|
1819
|
+
var delay3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1503
1820
|
async function using3(buses, body) {
|
|
1504
1821
|
try {
|
|
1505
1822
|
await body();
|
|
@@ -1509,96 +1826,121 @@ async function using3(buses, body) {
|
|
|
1509
1826
|
}
|
|
1510
1827
|
function runEventBusContract(name, make, opts = {}) {
|
|
1511
1828
|
describe8(`EventBusAdapter contract: ${name}`, () => {
|
|
1512
|
-
it9(
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1829
|
+
it9(
|
|
1830
|
+
"publish resolves after the matching handler has finished, and never calls a non-matching one",
|
|
1831
|
+
async () => {
|
|
1832
|
+
const bus = await make();
|
|
1833
|
+
await using3([bus], async () => {
|
|
1834
|
+
const received = [];
|
|
1835
|
+
const other = [];
|
|
1836
|
+
await bus.subscribe("test.event.v1", async (e) => {
|
|
1837
|
+
await delay3(50);
|
|
1838
|
+
received.push(e.payload);
|
|
1839
|
+
});
|
|
1840
|
+
await bus.subscribe("test.other.v1", async (e) => void other.push(e));
|
|
1841
|
+
await bus.publish(makeEvent("test.event.v1", { x: 1 }));
|
|
1842
|
+
expect9(received).toEqual([{ x: 1 }]);
|
|
1843
|
+
expect9(other).toEqual([]);
|
|
1520
1844
|
});
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1845
|
+
},
|
|
1846
|
+
CASE_MS
|
|
1847
|
+
);
|
|
1848
|
+
it9(
|
|
1849
|
+
"a handler that throws leaves publish resolved and every other handler called",
|
|
1850
|
+
async () => {
|
|
1851
|
+
const bus = await make();
|
|
1852
|
+
await using3([bus], async () => {
|
|
1853
|
+
const results = [];
|
|
1854
|
+
await bus.subscribe("error.test.v1", async () => {
|
|
1855
|
+
throw new Error("boom");
|
|
1856
|
+
});
|
|
1857
|
+
await bus.subscribe("error.test.v1", async () => void results.push("ok"));
|
|
1858
|
+
await expect9(bus.publish(makeEvent("error.test.v1"))).resolves.toBeUndefined();
|
|
1859
|
+
expect9(results).toEqual(["ok"]);
|
|
1533
1860
|
});
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1861
|
+
},
|
|
1862
|
+
CASE_MS
|
|
1863
|
+
);
|
|
1864
|
+
it9(
|
|
1865
|
+
"replay reaches derived subscriptions only",
|
|
1866
|
+
async () => {
|
|
1867
|
+
const bus = await make();
|
|
1868
|
+
await using3([bus], async () => {
|
|
1869
|
+
const derived = [];
|
|
1870
|
+
const sideEffect = [];
|
|
1871
|
+
await bus.subscribe("replay.test.v1", async (e) => void derived.push(e.id), {
|
|
1872
|
+
name: "contract-projection",
|
|
1873
|
+
derived: true
|
|
1874
|
+
});
|
|
1875
|
+
await bus.subscribe("replay.test.v1", async (e) => void sideEffect.push(e.id), {
|
|
1876
|
+
name: "contract-side-effect"
|
|
1877
|
+
});
|
|
1878
|
+
const events = [makeEvent("replay.test.v1"), makeEvent("replay.test.v1")];
|
|
1879
|
+
await bus.replay(events);
|
|
1880
|
+
expect9(derived).toEqual(events.map((e) => e.id));
|
|
1881
|
+
expect9(sideEffect).toEqual([]);
|
|
1547
1882
|
});
|
|
1548
|
-
|
|
1549
|
-
|
|
1883
|
+
},
|
|
1884
|
+
CASE_MS
|
|
1885
|
+
);
|
|
1886
|
+
it9(
|
|
1887
|
+
"publishBatch reaches each subscription in batch order, through wildcards and pattern arrays",
|
|
1888
|
+
async () => {
|
|
1889
|
+
const bus = await make();
|
|
1890
|
+
await using3([bus], async () => {
|
|
1891
|
+
const wildcard = [];
|
|
1892
|
+
const listed = [];
|
|
1893
|
+
await bus.subscribe("batch.*", async (e) => void wildcard.push(e.id));
|
|
1894
|
+
await bus.subscribe(["batch.a.v1", "batch.c.v1"], async (e) => void listed.push(e.id), {
|
|
1895
|
+
name: "contract-listed"
|
|
1896
|
+
});
|
|
1897
|
+
const events = ["batch.a.v1", "batch.b.v1", "batch.c.v1", "batch.a.v1"].map(
|
|
1898
|
+
(t, seq) => makeEvent(t, { seq })
|
|
1899
|
+
);
|
|
1900
|
+
await bus.publishBatch(events);
|
|
1901
|
+
expect9(wildcard).toEqual(events.map((e) => e.id));
|
|
1902
|
+
expect9(listed).toEqual(events.filter((e) => e.type !== "batch.b.v1").map((e) => e.id));
|
|
1550
1903
|
});
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1904
|
+
},
|
|
1905
|
+
CASE_MS
|
|
1906
|
+
);
|
|
1907
|
+
it9(
|
|
1908
|
+
"an unsubscribed handler receives nothing more",
|
|
1909
|
+
async () => {
|
|
1910
|
+
const bus = await make();
|
|
1911
|
+
await using3([bus], async () => {
|
|
1912
|
+
const received = [];
|
|
1913
|
+
const sub = await bus.subscribe("unsub.event.v1", async (e) => void received.push(e.id));
|
|
1914
|
+
const before = makeEvent("unsub.event.v1");
|
|
1915
|
+
await bus.publish(before);
|
|
1916
|
+
await bus.unsubscribe(sub);
|
|
1917
|
+
await bus.publish(makeEvent("unsub.event.v1"));
|
|
1918
|
+
expect9(received).toEqual([before.id]);
|
|
1565
1919
|
});
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
});
|
|
1585
|
-
}, CASE_MS);
|
|
1586
|
-
it9("on one instance, a named and an unnamed subscription each handle every event once", async () => {
|
|
1587
|
-
const bus = await make();
|
|
1588
|
-
await using3([bus], async () => {
|
|
1589
|
-
const named = [];
|
|
1590
|
-
const unnamed = [];
|
|
1591
|
-
await bus.subscribe("both.event.v1", async (e) => void named.push(e.id), {
|
|
1592
|
-
name: "contract-named"
|
|
1920
|
+
},
|
|
1921
|
+
CASE_MS
|
|
1922
|
+
);
|
|
1923
|
+
it9(
|
|
1924
|
+
"on one instance, a named and an unnamed subscription each handle every event once",
|
|
1925
|
+
async () => {
|
|
1926
|
+
const bus = await make();
|
|
1927
|
+
await using3([bus], async () => {
|
|
1928
|
+
const named = [];
|
|
1929
|
+
const unnamed = [];
|
|
1930
|
+
await bus.subscribe("both.event.v1", async (e) => void named.push(e.id), {
|
|
1931
|
+
name: "contract-named"
|
|
1932
|
+
});
|
|
1933
|
+
await bus.subscribe("both.event.v1", async (e) => void unnamed.push(e.id));
|
|
1934
|
+
const events = [makeEvent("both.event.v1"), makeEvent("both.event.v1")];
|
|
1935
|
+
for (const e of events) await bus.publish(e);
|
|
1936
|
+
expect9(named).toEqual(events.map((e) => e.id));
|
|
1937
|
+
expect9(unnamed).toEqual(named);
|
|
1593
1938
|
});
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
expect9(named).toEqual(events.map((e) => e.id));
|
|
1598
|
-
expect9(unnamed).toEqual(named);
|
|
1599
|
-
});
|
|
1600
|
-
}, CASE_MS);
|
|
1939
|
+
},
|
|
1940
|
+
CASE_MS
|
|
1941
|
+
);
|
|
1601
1942
|
eventBusDeliveryCases(make, opts);
|
|
1943
|
+
eventBusFailureCases(make, opts);
|
|
1602
1944
|
healthAndDowntime(
|
|
1603
1945
|
make,
|
|
1604
1946
|
opts.unreachable,
|
|
@@ -1636,7 +1978,7 @@ function runEventBusContract(name, make, opts = {}) {
|
|
|
1636
1978
|
expect9(onB.filter((id) => ids2.includes(id))).toEqual(ids2);
|
|
1637
1979
|
});
|
|
1638
1980
|
},
|
|
1639
|
-
|
|
1981
|
+
SLOW6
|
|
1640
1982
|
);
|
|
1641
1983
|
});
|
|
1642
1984
|
}
|
|
@@ -1685,7 +2027,7 @@ function runMediaStorageContract(name, make, options) {
|
|
|
1685
2027
|
}
|
|
1686
2028
|
|
|
1687
2029
|
// src/payments.ts
|
|
1688
|
-
import { randomUUID as
|
|
2030
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1689
2031
|
import { describe as describe10, expect as expect11, it as it11 } from "vitest";
|
|
1690
2032
|
var TOTAL = 1999;
|
|
1691
2033
|
async function rejectionCode(call) {
|
|
@@ -1703,7 +2045,7 @@ function runPaymentsContract(name, build, options = {}) {
|
|
|
1703
2045
|
const intent = await adapter.createPaymentIntent({
|
|
1704
2046
|
amountMinor: 1e3,
|
|
1705
2047
|
currency: "GBP",
|
|
1706
|
-
orderRef: `contract-${
|
|
2048
|
+
orderRef: `contract-${randomUUID4()}`
|
|
1707
2049
|
});
|
|
1708
2050
|
expect11(intent.id).toBeTruthy();
|
|
1709
2051
|
expect11(intent.clientSecret).toBeTruthy();
|
|
@@ -1738,11 +2080,11 @@ function runPaymentsContract(name, build, options = {}) {
|
|
|
1738
2080
|
const intent = await adapter.createPaymentIntent({
|
|
1739
2081
|
amountMinor: TOTAL,
|
|
1740
2082
|
currency: "GBP",
|
|
1741
|
-
orderRef: `contract-${
|
|
2083
|
+
orderRef: `contract-${randomUUID4()}`
|
|
1742
2084
|
});
|
|
1743
2085
|
if (intent.status !== "succeeded") await adapter.capturePayment(intent.id);
|
|
1744
2086
|
const half = Math.floor(TOTAL / 2);
|
|
1745
|
-
const key = `contract-refund-${
|
|
2087
|
+
const key = `contract-refund-${randomUUID4()}`;
|
|
1746
2088
|
const first = await adapter.refund(intent.id, half, "requested_by_customer", {
|
|
1747
2089
|
idempotencyKey: key
|
|
1748
2090
|
});
|
|
@@ -1788,11 +2130,11 @@ function runPaymentsContract(name, build, options = {}) {
|
|
|
1788
2130
|
}
|
|
1789
2131
|
|
|
1790
2132
|
// src/reviews.ts
|
|
1791
|
-
import { randomUUID as
|
|
2133
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1792
2134
|
import fc4 from "fast-check";
|
|
1793
2135
|
import { describe as describe11, expect as expect12, it as it12 } from "vitest";
|
|
1794
2136
|
async function seed(adapter, count) {
|
|
1795
|
-
const productId = `contract-reviews-${
|
|
2137
|
+
const productId = `contract-reviews-${randomUUID5()}`;
|
|
1796
2138
|
const seeded = [];
|
|
1797
2139
|
for (let i = 0; i < count; i++) {
|
|
1798
2140
|
const { review } = await adapter.submitReview({
|
|
@@ -1827,7 +2169,7 @@ function runReviewsContract(name, build, options = {}) {
|
|
|
1827
2169
|
"submitReview returns the created review",
|
|
1828
2170
|
async () => {
|
|
1829
2171
|
const adapter = await build();
|
|
1830
|
-
const productId = `contract-reviews-${
|
|
2172
|
+
const productId = `contract-reviews-${randomUUID5()}`;
|
|
1831
2173
|
const { review } = await adapter.submitReview({
|
|
1832
2174
|
productId,
|
|
1833
2175
|
customerId: null,
|
|
@@ -1908,13 +2250,13 @@ function runReviewsContract(name, build, options = {}) {
|
|
|
1908
2250
|
build,
|
|
1909
2251
|
options.unreachable,
|
|
1910
2252
|
"getProductReviews()",
|
|
1911
|
-
(adapter) => adapter.getProductReviews(`contract-reviews-${
|
|
2253
|
+
(adapter) => adapter.getProductReviews(`contract-reviews-${randomUUID5()}`, { limit: 1 })
|
|
1912
2254
|
);
|
|
1913
2255
|
});
|
|
1914
2256
|
}
|
|
1915
2257
|
|
|
1916
2258
|
// src/search.ts
|
|
1917
|
-
import { randomUUID as
|
|
2259
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
1918
2260
|
import fc5 from "fast-check";
|
|
1919
2261
|
import { describe as describe12, expect as expect13, it as it13 } from "vitest";
|
|
1920
2262
|
|
|
@@ -1958,7 +2300,7 @@ function tierOf(p, query) {
|
|
|
1958
2300
|
|
|
1959
2301
|
// src/search.ts
|
|
1960
2302
|
function runToken() {
|
|
1961
|
-
return `t${
|
|
2303
|
+
return `t${randomUUID6().replace(/-/g, "").slice(0, 15)}`;
|
|
1962
2304
|
}
|
|
1963
2305
|
var word = fc5.string({
|
|
1964
2306
|
unit: fc5.constantFrom("a", "b", "c", "d", "1", "2"),
|
|
@@ -2029,7 +2371,7 @@ function expectExactWalk(pages, expected, limit, label) {
|
|
|
2029
2371
|
expect13(page.items.length, `${label}: page ${i + 1} is empty`).toBeGreaterThan(0);
|
|
2030
2372
|
}
|
|
2031
2373
|
}
|
|
2032
|
-
var
|
|
2374
|
+
var SLOW7 = 18e4;
|
|
2033
2375
|
function runSearchContract(name, build, options = {}) {
|
|
2034
2376
|
const numRuns = options.numRuns ?? 25;
|
|
2035
2377
|
describe12(`SearchAdapter contract: ${name}`, () => {
|
|
@@ -2062,7 +2404,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2062
2404
|
"remove deletes documents from index",
|
|
2063
2405
|
async () => {
|
|
2064
2406
|
const adapter = await build();
|
|
2065
|
-
const tag = `remove-${
|
|
2407
|
+
const tag = `remove-${randomUUID6()}`;
|
|
2066
2408
|
await adapter.index([{ id: tag, type: "product", fields: { name: "To Remove" } }]);
|
|
2067
2409
|
await adapter.remove([tag]);
|
|
2068
2410
|
const result = await adapter.search("To Remove");
|
|
@@ -2161,7 +2503,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2161
2503
|
{ numRuns }
|
|
2162
2504
|
);
|
|
2163
2505
|
},
|
|
2164
|
-
|
|
2506
|
+
SLOW7
|
|
2165
2507
|
);
|
|
2166
2508
|
if (options.ranking) {
|
|
2167
2509
|
it13(
|
|
@@ -2226,7 +2568,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2226
2568
|
{ numRuns }
|
|
2227
2569
|
);
|
|
2228
2570
|
},
|
|
2229
|
-
|
|
2571
|
+
SLOW7
|
|
2230
2572
|
);
|
|
2231
2573
|
}
|
|
2232
2574
|
if (options.facets) {
|
|
@@ -2276,7 +2618,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2276
2618
|
{ numRuns }
|
|
2277
2619
|
);
|
|
2278
2620
|
},
|
|
2279
|
-
|
|
2621
|
+
SLOW7
|
|
2280
2622
|
);
|
|
2281
2623
|
}
|
|
2282
2624
|
describe12("hostile input", () => {
|
|
@@ -2355,7 +2697,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2355
2697
|
}
|
|
2356
2698
|
);
|
|
2357
2699
|
},
|
|
2358
|
-
|
|
2700
|
+
SLOW7
|
|
2359
2701
|
);
|
|
2360
2702
|
it13(
|
|
2361
2703
|
"input with no terms returns no items, total 0 and no next page, over an index that holds products",
|
|
@@ -2395,7 +2737,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2395
2737
|
await adapter.remove(products.map((p) => p.id));
|
|
2396
2738
|
}
|
|
2397
2739
|
},
|
|
2398
|
-
|
|
2740
|
+
SLOW7
|
|
2399
2741
|
);
|
|
2400
2742
|
it13(
|
|
2401
2743
|
"a malformed cursor (undecodable, negative, non-integer) rejects, and a cursor at or past total, as after a removal between two pages, is an empty page with nextCursor null",
|
|
@@ -2449,7 +2791,7 @@ function runSearchContract(name, build, options = {}) {
|
|
|
2449
2791
|
await adapter.remove(products.map((p) => p.id));
|
|
2450
2792
|
}
|
|
2451
2793
|
},
|
|
2452
|
-
|
|
2794
|
+
SLOW7
|
|
2453
2795
|
);
|
|
2454
2796
|
});
|
|
2455
2797
|
});
|
|
@@ -2542,8 +2884,72 @@ function adapterContext(slot, config, env = {}) {
|
|
|
2542
2884
|
return { slot, config, logger: silent, env };
|
|
2543
2885
|
}
|
|
2544
2886
|
|
|
2545
|
-
// src/
|
|
2887
|
+
// src/webhook.ts
|
|
2546
2888
|
import { describe as describe15, expect as expect16, it as it16 } from "vitest";
|
|
2889
|
+
import {
|
|
2890
|
+
webhookDeliveryProblem
|
|
2891
|
+
} from "@nymbalhq/types";
|
|
2892
|
+
function receiving(adapter) {
|
|
2893
|
+
expect16(typeof adapter.receiveWebhook, "the adapter has no receiveWebhook").toBe("function");
|
|
2894
|
+
return adapter;
|
|
2895
|
+
}
|
|
2896
|
+
async function receive(adapter, request) {
|
|
2897
|
+
return receiving(adapter).receiveWebhook(request.rawBody, request.headers);
|
|
2898
|
+
}
|
|
2899
|
+
function runWebhookContract(name, build, options) {
|
|
2900
|
+
describe15(`webhook contract: ${name}`, () => {
|
|
2901
|
+
it16(
|
|
2902
|
+
"idempotency: one delivery received twice gives one deliveryId and equal events",
|
|
2903
|
+
() => using(build, async (adapter) => {
|
|
2904
|
+
const first = await receive(adapter, options.delivery());
|
|
2905
|
+
const second = await receive(adapter, options.delivery());
|
|
2906
|
+
expect16(first.verified, "the signed delivery was not verified").toBe(true);
|
|
2907
|
+
expect16(typeof first.deliveryId === "string" && first.deliveryId !== "").toBe(true);
|
|
2908
|
+
expect16(second.deliveryId, "the same delivery got another deliveryId").toBe(
|
|
2909
|
+
first.deliveryId
|
|
2910
|
+
);
|
|
2911
|
+
expect16(second.events).toEqual(first.events);
|
|
2912
|
+
}),
|
|
2913
|
+
CASE_MS
|
|
2914
|
+
);
|
|
2915
|
+
const tampered = options.tampered;
|
|
2916
|
+
if (tampered !== void 0) {
|
|
2917
|
+
it16(
|
|
2918
|
+
"verification: a tampered delivery is not verified",
|
|
2919
|
+
() => using(build, async (adapter) => {
|
|
2920
|
+
const outcome = await receive(adapter, tampered()).then(
|
|
2921
|
+
(delivery) => ({ verified: delivery.verified, code: null }),
|
|
2922
|
+
(err) => ({
|
|
2923
|
+
verified: false,
|
|
2924
|
+
code: err?.code
|
|
2925
|
+
})
|
|
2926
|
+
);
|
|
2927
|
+
expect16(outcome.verified, "a tampered delivery was verified").toBe(false);
|
|
2928
|
+
if (outcome.code !== null) expect16(String(outcome.code)).toMatch(/webhook_invalid$/);
|
|
2929
|
+
}),
|
|
2930
|
+
CASE_MS
|
|
2931
|
+
);
|
|
2932
|
+
}
|
|
2933
|
+
it16(
|
|
2934
|
+
"namespace: every event is the adapter's own, declared and versioned",
|
|
2935
|
+
() => using(build, async (adapter) => {
|
|
2936
|
+
const delivery = await receive(adapter, options.delivery());
|
|
2937
|
+
expect16(delivery.verified, "the signed delivery was not verified").toBe(true);
|
|
2938
|
+
expect16(delivery.events.length, "the delivery carried no event to check").toBeGreaterThan(
|
|
2939
|
+
0
|
|
2940
|
+
);
|
|
2941
|
+
expect16(
|
|
2942
|
+
webhookDeliveryProblem(adapter.providerName, adapter.producesEvents, delivery),
|
|
2943
|
+
"the kernel would refuse this delivery"
|
|
2944
|
+
).toBeNull();
|
|
2945
|
+
}),
|
|
2946
|
+
CASE_MS
|
|
2947
|
+
);
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
// src/security.ts
|
|
2952
|
+
import { describe as describe16, expect as expect17, it as it17 } from "vitest";
|
|
2547
2953
|
var testConfig = {
|
|
2548
2954
|
rateLimit: {
|
|
2549
2955
|
api: { requests: 100, window: "1m", action: "block" }
|
|
@@ -2557,24 +2963,24 @@ var testConfig = {
|
|
|
2557
2963
|
}
|
|
2558
2964
|
};
|
|
2559
2965
|
function runSecurityContract(name, build, options = {}) {
|
|
2560
|
-
|
|
2561
|
-
|
|
2966
|
+
describe16(`SecurityAdapter contract: ${name}`, () => {
|
|
2967
|
+
it17(
|
|
2562
2968
|
"validate accepts a valid config",
|
|
2563
2969
|
async () => {
|
|
2564
2970
|
const adapter = await build();
|
|
2565
2971
|
const result = await adapter.validate(testConfig);
|
|
2566
|
-
|
|
2567
|
-
|
|
2972
|
+
expect17(typeof result.valid).toBe("boolean");
|
|
2973
|
+
expect17(Array.isArray(result.errors)).toBe(true);
|
|
2568
2974
|
},
|
|
2569
2975
|
CASE_MS
|
|
2570
2976
|
);
|
|
2571
|
-
|
|
2977
|
+
it17(
|
|
2572
2978
|
"validate of empty config reports errors",
|
|
2573
2979
|
async () => {
|
|
2574
2980
|
const adapter = await build();
|
|
2575
2981
|
const result = await adapter.validate({});
|
|
2576
2982
|
if (!result.valid) {
|
|
2577
|
-
|
|
2983
|
+
expect17(result.errors.length).toBeGreaterThan(0);
|
|
2578
2984
|
}
|
|
2579
2985
|
},
|
|
2580
2986
|
CASE_MS
|
|
@@ -2589,7 +2995,7 @@ function runSecurityContract(name, build, options = {}) {
|
|
|
2589
2995
|
}
|
|
2590
2996
|
|
|
2591
2997
|
// src/projection.ts
|
|
2592
|
-
import { describe as
|
|
2998
|
+
import { describe as describe17, it as it18, expect as expect18 } from "vitest";
|
|
2593
2999
|
function makeEvent2(id) {
|
|
2594
3000
|
return {
|
|
2595
3001
|
id,
|
|
@@ -2602,26 +3008,26 @@ function makeEvent2(id) {
|
|
|
2602
3008
|
};
|
|
2603
3009
|
}
|
|
2604
3010
|
function runProjectionContract(opts) {
|
|
2605
|
-
|
|
2606
|
-
|
|
3011
|
+
describe17("Projection idempotency contract", () => {
|
|
3012
|
+
it18("processing the same event twice applies the mutation only once", async () => {
|
|
2607
3013
|
const ctx = await opts.build();
|
|
2608
|
-
const
|
|
2609
|
-
await ctx.handle(
|
|
2610
|
-
await ctx.handle(
|
|
2611
|
-
|
|
3014
|
+
const event3 = makeEvent2("dedup-evt-001");
|
|
3015
|
+
await ctx.handle(event3);
|
|
3016
|
+
await ctx.handle(event3);
|
|
3017
|
+
expect18(await ctx.applicationCount()).toBe(1);
|
|
2612
3018
|
});
|
|
2613
|
-
|
|
3019
|
+
it18("distinct events each apply their mutation", async () => {
|
|
2614
3020
|
const ctx = await opts.build();
|
|
2615
3021
|
await ctx.handle(makeEvent2("distinct-evt-001"));
|
|
2616
3022
|
await ctx.handle(makeEvent2("distinct-evt-002"));
|
|
2617
|
-
|
|
3023
|
+
expect18(await ctx.applicationCount()).toBe(2);
|
|
2618
3024
|
});
|
|
2619
|
-
|
|
3025
|
+
it18("after reprocessing a batch the count equals unique event count", async () => {
|
|
2620
3026
|
const ctx = await opts.build();
|
|
2621
3027
|
const events = [makeEvent2("batch-001"), makeEvent2("batch-002"), makeEvent2("batch-003")];
|
|
2622
3028
|
for (const e of events) await ctx.handle(e);
|
|
2623
3029
|
for (const e of events) await ctx.handle(e);
|
|
2624
|
-
|
|
3030
|
+
expect18(await ctx.applicationCount()).toBe(3);
|
|
2625
3031
|
});
|
|
2626
3032
|
});
|
|
2627
3033
|
}
|
|
@@ -2640,13 +3046,16 @@ var suites = {
|
|
|
2640
3046
|
analytics: runAnalyticsContract,
|
|
2641
3047
|
shipping: runShippingContract,
|
|
2642
3048
|
tax: runTaxContract,
|
|
2643
|
-
ai: runAiContract,
|
|
2644
3049
|
documentStore: runDocumentStoreContract,
|
|
2645
3050
|
eventBus: runEventBusContract,
|
|
2646
|
-
media: (name, build, options = {}) => runMediaStorageContract(
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3051
|
+
media: (name, build, options = {}) => runMediaStorageContract(
|
|
3052
|
+
name,
|
|
3053
|
+
build,
|
|
3054
|
+
{
|
|
3055
|
+
fetchUrl: fetchOverHttp,
|
|
3056
|
+
...options
|
|
3057
|
+
}
|
|
3058
|
+
)
|
|
2650
3059
|
};
|
|
2651
3060
|
var contractSuites = suites;
|
|
2652
3061
|
export {
|
|
@@ -2655,7 +3064,6 @@ export {
|
|
|
2655
3064
|
adapterContext,
|
|
2656
3065
|
contractSuites,
|
|
2657
3066
|
matchesQuery,
|
|
2658
|
-
runAiContract,
|
|
2659
3067
|
runAnalyticsContract,
|
|
2660
3068
|
runDocumentStoreContract,
|
|
2661
3069
|
runEmailContract,
|
|
@@ -2668,6 +3076,7 @@ export {
|
|
|
2668
3076
|
runSecurityContract,
|
|
2669
3077
|
runShippingContract,
|
|
2670
3078
|
runTaxContract,
|
|
3079
|
+
runWebhookContract,
|
|
2671
3080
|
searchTerms,
|
|
2672
3081
|
toSearchDocument
|
|
2673
3082
|
};
|