@rotorsoft/act-tck 1.26.2 → 1.26.4

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.
@@ -7,9 +7,15 @@ import type { Store } from "@rotorsoft/act/types";
7
7
  export type StoreCapabilities = {
8
8
  /**
9
9
  * Adapter implements {@link Store.notify}. When `true`, the TCK runs
10
- * a basic subscribe/dispose smoke test. Cross-process LISTEN/NOTIFY
11
- * semantics are not exercised that needs two processes and stays
12
- * in the adapter's own suite.
10
+ * the cross-instance conformance cases: a listener receives commits
11
+ * from a *sibling* instance produced by the same `factory`, never its
12
+ * own commits (the port's self-filtering MUST), and exactly one
13
+ * notification per commit transaction carrying the full event batch.
14
+ * Requires the `factory` to produce instances sharing one backing
15
+ * store — e.g. two PostgresStores on the same schema/table, or two
16
+ * `withBroker` decorators on one broker. True cross-*process*
17
+ * LISTEN/NOTIFY plumbing needs two processes and stays in the
18
+ * adapter's own suite.
13
19
  */
14
20
  readonly notify?: boolean;
15
21
  /**
@@ -90,7 +96,8 @@ export type StoreTckOptions = {
90
96
  * `ValidationError` — never a silent approximation
91
97
  * - `subscribe` — idempotent re-subscribe, watermark return value
92
98
  * - `claim` / `ack` — lease lifecycle, dual frontiers, leased streams
93
- * not double-claimed
99
+ * not double-claimed, exact-source has-work matching, timed-out-lease
100
+ * retry accounting
94
101
  * - `block` — blocked streams hidden from claim, only same-drainer can block
95
102
  * - `reset` — restart watermarks (including blocked), no-op for missing
96
103
  * - `prioritize` — bulk priority updates by filter
@@ -99,7 +106,9 @@ export type StoreTckOptions = {
99
106
  * behind the closest safe snapshot, tail + subscriptions kept,
100
107
  * no-snapshot no-op, mixed full + windowed targets
101
108
  * - `query_streams` — filters, exact-match, pagination, blocked
102
- * - `notify` (capability-gated) — subscribe + dispose smoke test
109
+ * - `notify` (capability-gated) — cross-instance delivery, self-filtering
110
+ * (an instance never receives its own commits), one notification per
111
+ * commit transaction with the full event batch
103
112
  *
104
113
  * Tests namespace their streams with a per-test {@link uid} so the
105
114
  * suite is parallel-safe against a shared backing store (e.g., a real
@@ -1 +1 @@
1
- {"version":3,"file":"store-tck.d.ts","sourceRoot":"","sources":["../../src/store-tck.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAQV,KAAK,EAEN,MAAM,sBAAsB,CAAC;AAc9B;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;;;OASG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;CACnC,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IAC/C;;OAEG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,iBAAiB,CAAC;CAC3C,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,eAAe,KAAG,IAonGtD,CAAC"}
1
+ {"version":3,"file":"store-tck.d.ts","sourceRoot":"","sources":["../../src/store-tck.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAQV,KAAK,EAEN,MAAM,sBAAsB,CAAC;AAc9B;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC;;;;;;;;;OASG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,CAAC;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAC;CACnC,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;;;OAOG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;IAC/C;;OAEG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,iBAAiB,CAAC;CAC3C,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,eAAO,MAAM,WAAW,GAAI,SAAS,eAAe,KAAG,IAy4GtD,CAAC"}
package/dist/index.cjs CHANGED
@@ -1481,6 +1481,93 @@ var runStoreTck = (options) => {
1481
1481
  await fresh.dispose();
1482
1482
  }
1483
1483
  });
1484
+ (0, import_vitest9.it)("counts a timed-out lease reclaimed by another worker against the retry budget; ack resets it", async () => {
1485
+ const fresh = await options.factory();
1486
+ try {
1487
+ await fresh.drop();
1488
+ await fresh.seed();
1489
+ const s = `lease-timeout-${uid()}`;
1490
+ await fresh.subscribe([{ stream: s }]);
1491
+ const [e1] = await seed_stream(fresh, s, 1);
1492
+ const a = await fresh.claim(1, 0, `wA-${uid()}`, 0);
1493
+ (0, import_vitest9.expect)(a.find((l) => l.stream === s)?.retry).toBe(0);
1494
+ const b = await fresh.claim(1, 0, `wB-${uid()}`, 3e4);
1495
+ const b_lease = b.find((l) => l.stream === s);
1496
+ (0, import_vitest9.expect)(b_lease).toBeDefined();
1497
+ (0, import_vitest9.expect)(b_lease.retry).toBe(1);
1498
+ await fresh.ack([{ ...b_lease, at: e1.id }]);
1499
+ await seed_stream(fresh, s, 1);
1500
+ const c = await fresh.claim(1, 0, `wC-${uid()}`, 3e4);
1501
+ (0, import_vitest9.expect)(c.find((l) => l.stream === s)?.retry).toBe(0);
1502
+ } finally {
1503
+ await fresh.dispose();
1504
+ }
1505
+ });
1506
+ });
1507
+ (0, import_vitest9.describe)("claim source matching", () => {
1508
+ (0, import_vitest9.it)("treats source as an exact stream name \u2014 no substring or pattern overmatch", async () => {
1509
+ const fresh = await options.factory();
1510
+ try {
1511
+ await fresh.drop();
1512
+ await fresh.seed();
1513
+ const base = `src-${uid()}`;
1514
+ const sibling = `${base}2`;
1515
+ const target = `agg-${uid()}`;
1516
+ const control = `ctl-${uid()}`;
1517
+ await fresh.subscribe([
1518
+ { stream: target, source: base },
1519
+ // Control subscription sourced from the sibling — it DOES see
1520
+ // the sibling commit below, so the negative assertion runs
1521
+ // through a populated claim result.
1522
+ { stream: control, source: sibling }
1523
+ ]);
1524
+ const [seeded] = await seed_stream(fresh, base, 1);
1525
+ const [ctl_seeded] = await seed_stream(fresh, sibling, 1);
1526
+ const first = await fresh.claim(100, 0, `w-${uid()}`, 3e4);
1527
+ const mine = first.find((l) => l.stream === target);
1528
+ const ctl = first.find((l) => l.stream === control);
1529
+ (0, import_vitest9.expect)(mine).toBeDefined();
1530
+ (0, import_vitest9.expect)(ctl).toBeDefined();
1531
+ await fresh.ack([
1532
+ { ...mine, at: seeded.id },
1533
+ { ...ctl, at: ctl_seeded.id }
1534
+ ]);
1535
+ await seed_stream(fresh, sibling, 1);
1536
+ const second = await fresh.claim(100, 0, `w-${uid()}`, 3e4);
1537
+ (0, import_vitest9.expect)(second.find((l) => l.stream === control)).toBeDefined();
1538
+ (0, import_vitest9.expect)(second.find((l) => l.stream === target)).toBeUndefined();
1539
+ } finally {
1540
+ await fresh.dispose();
1541
+ }
1542
+ });
1543
+ (0, import_vitest9.it)("receives exactly its source stream's events", async () => {
1544
+ const fresh = await options.factory();
1545
+ try {
1546
+ await fresh.drop();
1547
+ await fresh.seed();
1548
+ const base = `src-${uid()}`;
1549
+ const target = `agg-${uid()}`;
1550
+ await fresh.subscribe([{ stream: target, source: base }]);
1551
+ const [e1] = await seed_stream(fresh, base, 1);
1552
+ const first = await fresh.claim(100, 0, `w-${uid()}`, 3e4);
1553
+ const f = first.find((l) => l.stream === target);
1554
+ (0, import_vitest9.expect)(f).toBeDefined();
1555
+ (0, import_vitest9.expect)(f.source).toBe(base);
1556
+ await fresh.ack([{ ...f, at: e1.id }]);
1557
+ const [e2] = await seed_stream(fresh, base, 1);
1558
+ const second = await fresh.claim(100, 0, `w-${uid()}`, 3e4);
1559
+ const sec = second.find((l) => l.stream === target);
1560
+ (0, import_vitest9.expect)(sec).toBeDefined();
1561
+ (0, import_vitest9.expect)(sec.at).toBe(e1.id);
1562
+ const events2 = await collect(fresh, {
1563
+ stream: sec.source,
1564
+ after: sec.at
1565
+ });
1566
+ (0, import_vitest9.expect)(events2.map((e) => e.id)).toEqual([e2.id]);
1567
+ } finally {
1568
+ await fresh.dispose();
1569
+ }
1570
+ });
1484
1571
  });
1485
1572
  import_vitest9.describe.skipIf(!caps.concurrent_claim)("concurrency (capability)", () => {
1486
1573
  (0, import_vitest9.it)("never double-leases a stream across concurrent claimers", async () => {
@@ -1512,6 +1599,51 @@ var runStoreTck = (options) => {
1512
1599
  await fresh.dispose();
1513
1600
  }
1514
1601
  });
1602
+ (0, import_vitest9.it)("does not hand an unexpired lease to a competing claimer", async () => {
1603
+ const fresh = await options.factory();
1604
+ try {
1605
+ await fresh.drop();
1606
+ await fresh.seed();
1607
+ const s = `unexpired-${uid()}`;
1608
+ const other = `unexpired-other-${uid()}`;
1609
+ await fresh.subscribe([{ stream: s }]);
1610
+ await seed_stream(fresh, s, 1);
1611
+ const a = await fresh.claim(100, 100, `wA-${uid()}`, 6e4);
1612
+ (0, import_vitest9.expect)(a.find((l) => l.stream === s)).toBeDefined();
1613
+ await fresh.subscribe([{ stream: other }]);
1614
+ await seed_stream(fresh, other, 1);
1615
+ const b = await fresh.claim(100, 100, `wB-${uid()}`, 6e4);
1616
+ (0, import_vitest9.expect)(b.find((l) => l.stream === other)).toBeDefined();
1617
+ (0, import_vitest9.expect)(b.find((l) => l.stream === s)).toBeUndefined();
1618
+ } finally {
1619
+ await fresh.dispose();
1620
+ }
1621
+ });
1622
+ (0, import_vitest9.it)("hands an expired lease to exactly one competing claimer, with retry accounting shared across workers", async () => {
1623
+ const fresh = await options.factory();
1624
+ try {
1625
+ await fresh.drop();
1626
+ await fresh.seed();
1627
+ const s = `expired-${uid()}`;
1628
+ await fresh.subscribe([{ stream: s }]);
1629
+ const [e1] = await seed_stream(fresh, s, 1);
1630
+ const dead = await fresh.claim(100, 0, `wDead-${uid()}`, 0);
1631
+ (0, import_vitest9.expect)(dead.find((l) => l.stream === s)?.retry).toBe(0);
1632
+ const [b, c] = await Promise.all([
1633
+ fresh.claim(100, 100, `wB-${uid()}`, 6e4),
1634
+ fresh.claim(100, 100, `wC-${uid()}`, 6e4)
1635
+ ]);
1636
+ const winners = [...b, ...c].filter((l) => l.stream === s);
1637
+ (0, import_vitest9.expect)(winners).toHaveLength(1);
1638
+ (0, import_vitest9.expect)(winners[0].retry).toBe(1);
1639
+ await fresh.ack([{ ...winners[0], at: e1.id }]);
1640
+ await seed_stream(fresh, s, 1);
1641
+ const again = await fresh.claim(100, 0, `wNext-${uid()}`, 6e4);
1642
+ (0, import_vitest9.expect)(again.find((l) => l.stream === s)?.retry).toBe(0);
1643
+ } finally {
1644
+ await fresh.dispose();
1645
+ }
1646
+ });
1515
1647
  });
1516
1648
  (0, import_vitest9.describe)("block", () => {
1517
1649
  (0, import_vitest9.it)("hides blocked streams from claim", async () => {
@@ -3502,6 +3634,89 @@ var runStoreTck = (options) => {
3502
3634
  await Promise.resolve(disposer());
3503
3635
  }
3504
3636
  });
3637
+ (0, import_vitest9.it)("does not deliver an instance's own commits (self-filtering)", async () => {
3638
+ const received = [];
3639
+ let resolve_sentinel;
3640
+ const sentinel_arrived = new Promise((res) => {
3641
+ resolve_sentinel = res;
3642
+ });
3643
+ const own = `notify-self-${uid()}`;
3644
+ const remote = `notify-remote-${uid()}`;
3645
+ const sentinel = `notify-sentinel-${uid()}`;
3646
+ const disposer = await store.notify.call(store, (n) => {
3647
+ received.push(n);
3648
+ if (n.stream === sentinel) resolve_sentinel();
3649
+ });
3650
+ const writer = await options.factory();
3651
+ try {
3652
+ await store.commit(
3653
+ own,
3654
+ [inc(1)],
3655
+ make_meta({ stream: own })
3656
+ );
3657
+ await writer.commit(
3658
+ remote,
3659
+ [inc(1)],
3660
+ make_meta({ stream: remote })
3661
+ );
3662
+ await writer.commit(
3663
+ sentinel,
3664
+ [inc(1)],
3665
+ make_meta({ stream: sentinel })
3666
+ );
3667
+ await sentinel_arrived;
3668
+ (0, import_vitest9.expect)(received.find((n) => n.stream === own)).toBeUndefined();
3669
+ (0, import_vitest9.expect)(received.find((n) => n.stream === remote)).toBeDefined();
3670
+ (0, import_vitest9.expect)(received.find((n) => n.stream === sentinel)).toBeDefined();
3671
+ } finally {
3672
+ await writer.dispose();
3673
+ await Promise.resolve(disposer());
3674
+ }
3675
+ });
3676
+ (0, import_vitest9.it)("delivers one notification per commit transaction carrying the full event batch", async () => {
3677
+ const received = [];
3678
+ let resolve_sentinel;
3679
+ const sentinel_arrived = new Promise((res) => {
3680
+ resolve_sentinel = res;
3681
+ });
3682
+ const batch = `notify-batch-${uid()}`;
3683
+ const sentinel = `notify-batch-sentinel-${uid()}`;
3684
+ const disposer = await store.notify.call(store, (n) => {
3685
+ received.push(n);
3686
+ if (n.stream === sentinel) resolve_sentinel();
3687
+ });
3688
+ const writer = await options.factory();
3689
+ try {
3690
+ await writer.commit(
3691
+ batch,
3692
+ [inc(1), inc(2), inc(3)],
3693
+ make_meta({ stream: batch })
3694
+ );
3695
+ await writer.commit(
3696
+ sentinel,
3697
+ [inc(1)],
3698
+ make_meta({ stream: sentinel })
3699
+ );
3700
+ await sentinel_arrived;
3701
+ const batch_notifications = received.filter(
3702
+ (n) => n.stream === batch
3703
+ );
3704
+ (0, import_vitest9.expect)(batch_notifications).toHaveLength(1);
3705
+ const events2 = batch_notifications[0].events;
3706
+ (0, import_vitest9.expect)(events2).toHaveLength(3);
3707
+ (0, import_vitest9.expect)(events2.map((e) => e.name)).toEqual([
3708
+ "Incremented",
3709
+ "Incremented",
3710
+ "Incremented"
3711
+ ]);
3712
+ (0, import_vitest9.expect)([...events2.map((e) => e.id)].sort((a, b) => a - b)).toEqual(
3713
+ events2.map((e) => e.id)
3714
+ );
3715
+ } finally {
3716
+ await writer.dispose();
3717
+ await Promise.resolve(disposer());
3718
+ }
3719
+ });
3505
3720
  });
3506
3721
  }
3507
3722
  });